chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
7 changed files with 1221 additions and 11 deletions
Showing only changes of commit cbfccaa499 - Show all commits
+141
View File
@@ -0,0 +1,141 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>스팸 · 원문 · 오래 걸리는 처리 상태 모음</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.on-dark:focus-visible{outline-color:#9ED6DB}
.ghost{background:transparent;color:#344054}
.ghost:hover{background:#F2F4F7;color:#171C26}
.danger{background:transparent;color:#B42318}
.danger:hover{background:#FEF3F2;color:#912018}
.menu-item{background:transparent;color:#171C26}
.menu-item:hover{background:#F2F4F7}
</style>
</helmet>
<div style="width: 1440px; min-height: 1320px; box-sizing: border-box; padding: 48px; display: flex; flex-direction: column; gap: 28px; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<div style="display: flex; flex-direction: column; gap: 6px; max-width: 900px">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">스팸 · 원문 · 오래 걸리는 처리</h1>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">4단계에서 읽기 화면 · 도구 줄 · 알림에 더해지는 것들입니다. 메일함 만들기 · 이름 바꾸기 · 지우기와 휴지통 비우기는 「메일함 관리」 보드, 비밀번호와 부재중 자동 응답은 설정 보드에서 눌러 볼 수 있습니다.</p>
</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">읽기 화면 도구 줄 — 받은편지함 등</h2>
<p style="margin: 0; font-size: 12px; color: #667085">스팸 신고 · 삭제 뒤에 ⋯ (더 보기 — 원문 내려받기)</p>
</div>
<div style="position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px; border: 1px solid #E6E8EC; border-radius: 10px">
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 14 4 9 9 4"></polyline><path d="M20 20v-7a4 4 0 0 0-4-4H4"></path></svg><span>답장</span></button>
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="7 17 2 12 7 7"></polyline><polyline points="12 17 7 12 12 7"></polyline><path d="M22 18v-2a4 4 0 0 0-4-4H7"></path></svg><span>전체 답장</span></button>
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 14 20 9 15 4"></polyline><path d="M4 20v-7a4 4 0 0 1 4-4h12"></path></svg><span>전달</span></button>
<span aria-hidden="true" style="width: 1px; height: 20px; margin: 0 6px; background: #E6E8EC"></span>
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><span>스팸 신고</span></button>
<button type="button" class="danger" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg><span>삭제</span></button>
<div style="position: relative">
<button type="button" class="ghost" aria-label="더 보기" aria-haspopup="menu" aria-expanded="true" title="더 보기" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
<div role="menu" aria-label="더 보기" style="position: absolute; top: 46px; right: 0; z-index: 5; min-width: 200px; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.14)">
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg><span>원문 내려받기 (.eml)</span></button>
</div>
</div>
</div>
<p style="margin: 0; padding-top: 52px; font-size: 12.5px; color: #4A5363">「스팸 신고」는 메일을 스팸메일함으로 옮기고 스팸 표시를 붙입니다(알림에 [되돌리기]). 「원문 내려받기」는 받은 그대로의 메일을 .eml 파일로 내려받습니다 — 다른 메일 프로그램에서 열거나 보관할 때 씁니다.</p>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">읽기 화면 도구 줄 — 스팸메일함</h2>
<p style="margin: 0; font-size: 12px; color: #667085">스팸 신고 자리에 스팸 아님</p>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px; border: 1px solid #E6E8EC; border-radius: 10px">
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 14 4 9 9 4"></polyline><path d="M20 20v-7a4 4 0 0 0-4-4H4"></path></svg><span>답장</span></button>
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 14 20 9 15 4"></polyline><path d="M4 20v-7a4 4 0 0 1 4-4h12"></path></svg><span>전달</span></button>
<span aria-hidden="true" style="width: 1px; height: 20px; margin: 0 6px; background: #E6E8EC"></span>
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; color: #0A5F67; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg><span>스팸 아님</span></button>
<button type="button" class="danger" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg><span>삭제</span></button>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">「스팸 아님」은 받은편지함으로 되돌리고 스팸 표시를 「스팸 아님」으로 바꿉니다(알림에 [되돌리기]).</p>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">여러 통 도구 줄 — 스팸 신고 · 스팸 아님</h2>
<p style="margin: 0; font-size: 12px; color: #667085">이동 ▾ 와 삭제 사이</p>
</div>
<div role="toolbar" aria-label="고른 메일" style="display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 14px; border: 1px solid #E6E8EC; border-radius: 10px; background: #F3F8F8">
<span style="margin-right: 10px; font-size: 14px; font-weight: 700">3통 선택됨</span>
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><span>읽음 표시</span></button>
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><span>이동</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><span>스팸 신고</span></button>
<button type="button" class="danger" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><span>삭제</span></button>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">스팸메일함에서 고르면 「스팸 신고」가 「스팸 아님」이 됩니다. 좁은 목록 칸에서는 지금처럼 아이콘만 보이고 이름은 툴팁으로 읽힙니다.</p>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">오래 걸리는 처리 — 모두 읽음 · 비우기 · 메일함 지우기</h2>
<p style="margin: 0; font-size: 12px; color: #667085">수천 통이면 서버가 나눠 처리한다 — 끝날 때까지 알림이 남는다</p>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M21 12a9 9 0 1 1-6.22-8.56"></path></svg>
<span style="flex: 1 1 auto; font-weight: 500">받은편지함의 메일을 읽음으로 표시하는 중 · <span style="font-variant-numeric: tabular-nums">600 / 1,234통</span></span>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">받은편지함의 메일 1,234통을 읽음으로 표시했습니다.</span>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">처리하는 동안 다른 메일을 읽고 써도 됩니다. 도중에 실패하면 "1,234통 가운데 600통까지 처리했습니다 — 다시 시도해 주세요." 로 알리고, 다시 하면 남은 것만 처리합니다.</p>
</section>
<section style="grid-column: 1 / -1; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">알림 문구</h2>
<p style="margin: 0; font-size: 12px; color: #667085">오른쪽 아래 · 되돌리기가 있으면 10초</p>
</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px">
<div role="status" style="display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">메일 2통을 스팸메일함으로 옮겼습니다.</span>
<button type="button" class="on-dark" style="flex: 0 0 auto; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #9ED6DB; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).</span>
<button type="button" class="on-dark" style="flex: 0 0 auto; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #9ED6DB; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">‘거래처’ 메일함을 지웠습니다. 안의 메일 12통은 휴지통에 있습니다.</span>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">휴지통을 비웠습니다 — 메일 21통.</span>
</div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">메일함 만들기 · 이름 바꾸기는 메일함 패널에 바로 나타나므로 짧은 알림("‘협력사’ 메일함을 만들었습니다.")만 띄웁니다. 비우기 · 메일함 지우기는 되돌릴 수 없어 [되돌리기]가 없습니다.</p>
</section>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1320}}'>
class Component extends DCLogic {
renderVals() {
return {};
}
}
</script>
</body>
</html>
+426
View File
@@ -0,0 +1,426 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>메일함 관리 · 휴지통 비우기</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.on-dark:focus-visible{outline-color:#9ED6DB}
.ghost{background:transparent;color:#344054}
.ghost:hover{background:#F2F4F7;color:#171C26}
.danger{background:transparent;color:#B42318}
.danger:hover{background:#FEF3F2;color:#912018}
.mb-row{position:relative}
.mb-row .mb-more{opacity:0}
.mb-row:hover .mb-more,.mb-row:focus-within .mb-more,.mb-row.is-open .mb-more{opacity:1}
.mb-more:hover{background:#E6E8EC}
.menu-item{background:transparent;color:#171C26}
.menu-item:hover{background:#F2F4F7}
.menu-item.is-danger{color:#B42318}
.menu-item.is-danger:hover{background:#FEF3F2}
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
.field:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
.field.is-error{border-color:#B42318}
</style>
</helmet>
<div style="min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
</a>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
<li>
<a href="MainList.dc.html" aria-current="page" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>메일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>캘린더</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span>파일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>
<span>ERP</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span>주소록</span>
</a>
</li>
</ul>
<a href="Settings.dc.html" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>설정</span>
</a>
</nav>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
<h1 style="margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -0.01em">메일</h1>
<form role="search" style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; margin: 0; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" aria-label="메일 검색" placeholder="메일 검색" style="flex: 1 1 auto; min-width: 0; border: 0; outline: none; background: transparent; font: inherit; color: #171C26">
</form>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</a>
</div>
</header>
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
<nav aria-label="메일함" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
<a href="Compose.dc.html" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font-size: 13.5px; font-weight: 600; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>메일 쓰기</span>
</a>
<div style="display: flex; flex-direction: column; gap: 1px">
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ roleBoxes }}" as="b" hint-placeholder-count="5">
<li class="{{ b.rowClass }}" style="display: flex; align-items: center; border-radius: 7px; background: {{ b.bg }}">
<button type="button" onClick="{{ b.pick }}" aria-current="{{ b.current }}" style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 4px 0 10px; border: 0; background: transparent; color: {{ b.fg }}; font: inherit; font-weight: {{ b.fw }}; text-align: left; cursor: pointer">
<sc-if value="{{ b.isInbox }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg></sc-if>
<sc-if value="{{ b.isSent }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg></sc-if>
<sc-if value="{{ b.isDrafts }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg></sc-if>
<sc-if value="{{ b.isJunk }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg></sc-if>
<sc-if value="{{ b.isTrash }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></sc-if>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ b.name }}</span>
<sc-if value="{{ b.hasUnread }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; font-weight: 600; color: {{ b.countColor }}; font-variant-numeric: tabular-nums">{{ b.unread }}</span></sc-if>
</button>
<button type="button" class="mb-more" onClick="{{ b.toggleMenu }}" aria-label="{{ b.moreLabel }}" aria-haspopup="menu" aria-expanded="{{ b.openStr }}" style="flex: 0 0 auto; width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; background: transparent; color: #4A5363; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg>
</button>
<sc-if value="{{ b.open }}" hint-placeholder-val="{{ false }}">
<div role="menu" aria-label="{{ b.menuLabel }}" style="position: absolute; top: 40px; right: 0; z-index: 6; min-width: 196px; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.14)">
<sc-for list="{{ b.items }}" as="it" hint-placeholder-count="2">
<button type="button" role="menuitem" class="{{ it.cls }}" onClick="{{ it.run }}" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">{{ it.label }}</button>
</sc-for>
</div>
</sc-if>
</li>
</sc-for>
</ul>
<p style="margin: 12px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">내 메일함</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ myBoxes }}" as="b" hint-placeholder-count="3">
<li class="{{ b.rowClass }}" style="display: flex; align-items: center; border-radius: 7px; background: {{ b.bg }}">
<button type="button" onClick="{{ b.pick }}" aria-current="{{ b.current }}" style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 4px 0 {{ b.indent }}px; border: 0; background: transparent; color: {{ b.fg }}; font: inherit; font-weight: {{ b.fw }}; text-align: left; cursor: pointer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ b.name }}</span>
<sc-if value="{{ b.hasUnread }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; font-weight: 600; color: {{ b.countColor }}; font-variant-numeric: tabular-nums">{{ b.unread }}</span></sc-if>
</button>
<button type="button" class="mb-more" onClick="{{ b.toggleMenu }}" aria-label="{{ b.moreLabel }}" aria-haspopup="menu" aria-expanded="{{ b.openStr }}" style="flex: 0 0 auto; width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; background: transparent; color: #4A5363; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg>
</button>
<sc-if value="{{ b.open }}" hint-placeholder-val="{{ false }}">
<div role="menu" aria-label="{{ b.menuLabel }}" style="position: absolute; top: 40px; right: 0; z-index: 6; min-width: 196px; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.14)">
<sc-for list="{{ b.items }}" as="it" hint-placeholder-count="4">
<button type="button" role="menuitem" class="{{ it.cls }}" onClick="{{ it.run }}" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">{{ it.label }}</button>
</sc-for>
</div>
</sc-if>
</li>
</sc-for>
</ul>
<button type="button" class="ghost" onClick="{{ openNewTop }}" style="display: flex; align-items: center; gap: 10px; min-height: 44px; margin-top: 2px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 500; text-align: left; cursor: pointer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>새 메일함</span>
</button>
</div>
<div style="margin-top: auto; display: flex; flex-direction: column; gap: 10px">
<a href="SettingsVacation.dc.html" style="display: flex; align-items: flex-start; gap: 8px; min-height: 44px; box-sizing: border-box; padding: 8px 10px; border-radius: 8px; background: #FFF7E6; color: #7A5200; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span style="display: flex; flex-direction: column; line-height: 1.4"><span style="font-weight: 600">부재중 자동 응답 켜짐</span><span style="font-size: 12px">11월 3일까지 · 설정</span></span>
</a>
<div style="display: flex; flex-direction: column; gap: 6px; padding: 12px 10px 2px; border-top: 1px solid #E6E8EC">
<div style="display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: #667085">
<span>메일함 사용량</span>
<span style="font-variant-numeric: tabular-nums">0.9 / 5 GB</span>
</div>
<div style="height: 4px; border-radius: 2px; background: #E4E7EC">
<div style="width: 18%; height: 4px; border-radius: 2px; background: #0E7C86"></div>
</div>
</div>
</div>
</nav>
<main style="flex: 999 1 560px; min-width: 0; display: flex; flex-wrap: wrap; align-items: stretch">
<section aria-labelledby="list-title" style="flex: 1 1 384px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column; border-right: 1px solid #E6E8EC">
<div style="display: flex; align-items: center; gap: 8px; min-height: 52px; box-sizing: border-box; padding: 4px 4px 4px 16px; border-bottom: 1px solid #E6E8EC">
<h2 id="list-title" style="margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">{{ boxName }}</h2>
<span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ countText }}</span>
<sc-if value="{{ canEmpty }}" hint-placeholder-val="{{ false }}">
<button type="button" class="danger" onClick="{{ openEmpty }}" style="margin-left: auto; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
<span>{{ emptyLabel }}</span>
</button>
</sc-if>
</div>
<sc-if value="{{ hasItems }}" hint-placeholder-val="{{ true }}">
<ul aria-label="{{ listLabel }}" style="list-style: none; margin: 0; padding: 0">
<sc-for list="{{ items }}" as="item" hint-placeholder-count="4">
<li style="display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 50px; box-sizing: border-box; padding: 6px 16px; border-bottom: 1px solid #EEF0F3; line-height: 1.4">
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="font-weight: {{ item.fw }}; color: {{ item.whoColor }}">{{ item.who }}</span><span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ item.time }}</span></span>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.subFw }}">{{ item.subject }}</span>
</li>
</sc-for>
</ul>
</sc-if>
<sc-if value="{{ noItems }}" hint-placeholder-val="{{ false }}">
<div style="display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 80px 24px; text-align: center">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg>
<p style="margin: 6px 0 0; font-size: 14px; font-weight: 600">{{ emptyText }}</p>
</div>
</sc-if>
</section>
<div style="flex: 999 1 480px; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 520px; padding: 24px; text-align: center">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<p style="margin: 6px 0 0; font-size: 14px; font-weight: 600">메일을 선택하세요</p>
<p style="margin: 0; font-size: 12.5px; color: #667085">메일함 이름 옆 ⋯ 를 누르면 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 지우기가 있습니다.</p>
</div>
</main>
</div>
</div>
<sc-if value="{{ dialogOpen }}" hint-placeholder-val="{{ false }}">
<div style="position: fixed; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(16, 24, 28, 0.32)">
<div role="{{ dialogRole }}" aria-modal="true" aria-labelledby="mb-dialog-title" style="width: min(400px, 100%); box-sizing: border-box; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; color: #171C26; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 10px">
<h3 id="mb-dialog-title" style="margin: 0; font-size: 15px; font-weight: 700">{{ dialogTitle }}</h3>
<sc-if value="{{ dialogHasName }}" hint-placeholder-val="{{ false }}">
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="mb-name" style="font-size: 12.5px; font-weight: 600; color: #4A5363">이름</label>
<input id="mb-name" class="{{ nameClass }}" type="text" value="{{ nameValue }}" maxlength="64" aria-invalid="{{ nameInvalid }}" aria-describedby="mb-name-help" style="height: 44px; padding: 0 12px">
<p id="mb-name-help" style="margin: 0; font-size: 12.5px; color: {{ nameHelpColor }}">{{ nameHelp }}</p>
</div>
</sc-if>
<sc-if value="{{ dialogHasText }}" hint-placeholder-val="{{ false }}">
<p style="margin: 0; color: #4A5363">{{ dialogText }}</p>
</sc-if>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
<sc-if value="{{ dialogHasConfirm }}" hint-placeholder-val="{{ true }}">
<button type="button" onClick="{{ confirmDialog }}" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; background: {{ confirmBg }}; color: #FFFFFF; font: inherit; font-weight: 600; cursor: pointer">{{ confirmLabel }}</button>
</sc-if>
<button type="button" class="ghost" onClick="{{ closeDialog }}" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">{{ cancelLabel }}</button>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ hasToast }}" hint-placeholder-val="{{ false }}">
<div role="status" style="position: fixed; right: 20px; bottom: 20px; z-index: 20; width: min(400px, calc(100vw - 40px)); box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">{{ toastText }}</span>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"nameError":{"editor":"boolean","default":false},"$preview":{"width":1440,"height":900}}'>
class Component extends DCLogic {
state = {
box: 'trash',
menu: null,
dialog: null,
toast: null,
read: {},
emptied: {},
mine: [
{ id: 'p', name: '프로젝트', parent: null, unread: 2, count: 18 },
{ id: 'pq', name: '견적', parent: 'p', unread: 0, count: 6 },
{ id: 'c', name: '거래처', parent: null, unread: 1, count: 12 },
],
};
renderVals() {
const st = this.state;
const ROLES = [
{ id: 'inbox', name: '받은편지함', unread: 3, count: 148 },
{ id: 'sent', name: '보낸편지함', unread: 0, count: 64 },
{ id: 'drafts', name: '임시보관함', unread: 0, count: 2 },
{ id: 'junk', name: '스팸메일함', unread: 1, count: 5 },
{ id: 'trash', name: '휴지통', unread: 0, count: 21 },
];
// 받침에 맞는 조사 (이/가 · 을/를)
const hasFinal = (w) => { const c = w.charCodeAt(w.length - 1); return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 !== 0; };
const iga = (w) => w + (hasFinal(w) ? '이' : '가');
const eulreul = (w) => w + (hasFinal(w) ? '을' : '를');
const unreadOf = (b) => (st.read[b.id] ? 0 : b.unread);
const countOf = (b) => (st.emptied[b.id] ? 0 : b.count);
const close = () => this.setState({ menu: null });
const markRead = (b) => this.setState({ menu: null, read: { ...st.read, [b.id]: true }, toast: `${b.name}의 메일 ${unreadOf(b)}통을 읽음으로 표시했습니다.` });
const style = (b) => {
const active = b.id === st.box;
return {
current: active ? 'page' : 'false',
bg: active ? '#E3F2F3' : 'transparent',
fg: active ? '#0A5F67' : '#344054',
fw: active ? '600' : '500',
countColor: active ? '#0A5F67' : '#171C26',
};
};
const base = (b, items) => {
const open = st.menu === b.id;
return {
name: b.name,
unread: unreadOf(b),
hasUnread: unreadOf(b) > 0,
...style(b),
open: open,
openStr: open ? 'true' : 'false',
rowClass: open ? 'mb-row is-open' : 'mb-row',
moreLabel: `${b.name} 메일함 메뉴`,
menuLabel: `${b.name} 메일함`,
pick: () => this.setState({ box: b.id, menu: null }),
toggleMenu: () => this.setState({ menu: open ? null : b.id }),
items: items,
};
};
const readItem = (b) => (unreadOf(b) > 0 ? [{ label: '모두 읽음으로 표시', cls: 'menu-item', run: () => markRead(b) }] : []);
const roleBoxes = ROLES.map((b) => {
const items = [...readItem(b)];
if (b.id === 'inbox') items.push({ label: '하위 메일함 만들기', cls: 'menu-item', run: () => this.setState({ menu: null, dialog: { kind: 'new', parent: 'inbox' } }) });
if (b.id === 'trash' || b.id === 'junk') items.push({ label: b.id === 'trash' ? '휴지통 비우기' : '스팸메일함 비우기', cls: 'menu-item is-danger', run: () => this.setState({ menu: null, dialog: { kind: 'empty', id: b.id } }) });
if (items.length === 0) items.push({ label: '메뉴 닫기', cls: 'menu-item', run: close });
return {
...base(b, items),
isInbox: b.id === 'inbox', isSent: b.id === 'sent', isDrafts: b.id === 'drafts', isJunk: b.id === 'junk', isTrash: b.id === 'trash',
};
});
const ordered = [];
const visit = (parent, depth) => st.mine.filter((m) => m.parent === parent).forEach((m) => { ordered.push({ m, depth }); visit(m.id, depth + 1); });
visit(null, 0);
const myBoxes = ordered.map(({ m, depth }) => ({
...base(m, [
...readItem(m),
{ label: '하위 메일함 만들기', cls: 'menu-item', run: () => this.setState({ menu: null, dialog: { kind: 'new', parent: m.id } }) },
{ label: '이름 바꾸기', cls: 'menu-item', run: () => this.setState({ menu: null, dialog: { kind: 'rename', id: m.id } }) },
{ label: '지우기', cls: 'menu-item is-danger', run: () => this.setState({ menu: null, dialog: { kind: 'delete', id: m.id } }) },
]),
indent: 10 + depth * 16,
}));
const all = [...ROLES, ...st.mine];
const cur = all.find((b) => b.id === st.box) || ROLES[0];
const curCount = countOf(cur);
const SAMPLE = [
{ who: '호스팅 안내', subject: '(광고) 서버 호스팅 연말 할인 안내', time: '10월 21일' },
{ who: '세미나 사무국', subject: '[초대] 클라우드 보안 세미나 사전 등록', time: '10월 18일' },
{ who: '박준호', subject: '백업 스크립트 테스트 (무시하세요)', time: '10월 15일' },
{ who: '총무팀', subject: '[총무팀] 사무실 공조 설비 점검 일정 안내', time: '10월 14일' },
];
const items = curCount === 0 ? [] : SAMPLE.map((x, i) => ({ ...x, fw: i === 0 && unreadOf(cur) > 0 ? '650' : '450', whoColor: '#344054', subFw: '400' }));
// 대화상자
const d = st.dialog;
const target = d && d.id ? all.find((b) => b.id === d.id) : null;
const parentBox = d && d.parent ? all.find((b) => b.id === d.parent) : null;
const hasChild = target ? st.mine.some((m) => m.parent === target.id) : false;
const nameError = Boolean(this.props.nameError);
let dialog = { title: '', role: 'dialog', hasName: false, hasText: false, text: '', hasConfirm: true, confirmLabel: '', confirmBg: '#0E7C86', cancelLabel: '취소', confirm: () => {} };
if (d && d.kind === 'new') {
dialog = {
...dialog,
title: parentBox ? `‘${parentBox.name}’ 안에 새 메일함` : '새 메일함',
hasName: true,
confirmLabel: '만들기',
confirm: () => this.setState({ dialog: null, mine: [...st.mine, { id: `n${st.mine.length}`, name: '협력사', parent: d.parent === 'inbox' ? null : d.parent, unread: 0, count: 0 }], toast: '‘협력사’ 메일함을 만들었습니다.' }),
};
} else if (d && d.kind === 'rename' && target) {
dialog = { ...dialog, title: '메일함 이름 바꾸기', hasName: true, confirmLabel: '바꾸기', confirm: () => this.setState({ dialog: null, mine: st.mine.map((m) => (m.id === target.id ? { ...m, name: `${m.name} (바꿈)` } : m)), toast: '메일함 이름을 바꿨습니다.' }) };
} else if (d && d.kind === 'delete' && target) {
dialog = hasChild
? { ...dialog, role: 'alertdialog', title: `‘${target.name}’ 메일함을 지울 수 없습니다`, hasText: true, text: '하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.', hasConfirm: false, cancelLabel: '확인' }
: {
...dialog,
role: 'alertdialog',
title: `‘${target.name}’ 메일함을 지울까요?`,
hasText: true,
text: countOf(target) > 0 ? `안의 메일 ${countOf(target)}통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다.` : '빈 메일함입니다. 지운 메일함은 되돌릴 수 없습니다.',
confirmLabel: '지우기',
confirmBg: '#B42318',
confirm: () => this.setState({ dialog: null, box: st.box === target.id ? 'inbox' : st.box, mine: st.mine.filter((m) => m.id !== target.id), toast: countOf(target) > 0 ? `‘${target.name}’ 메일함을 지웠습니다. 안의 메일 ${countOf(target)}통은 휴지통에 있습니다.` : `‘${target.name}’ 메일함을 지웠습니다.` }),
};
} else if (d && d.kind === 'empty' && target) {
dialog = {
...dialog,
role: 'alertdialog',
title: `${target.name}의 메일 ${countOf(target)}통을 모두 영구 삭제할까요?`,
hasText: true,
text: '지운 메일은 되돌릴 수 없습니다.',
confirmLabel: '영구 삭제',
confirmBg: '#B42318',
confirm: () => this.setState({ dialog: null, emptied: { ...st.emptied, [target.id]: true }, toast: `${eulreul(target.name)} 비웠습니다 — 메일 ${countOf(target)}통.` }),
};
}
const curIsEmptiable = cur.id === 'trash' || cur.id === 'junk';
return {
roleBoxes: roleBoxes,
myBoxes: myBoxes,
openNewTop: () => this.setState({ menu: null, dialog: { kind: 'new', parent: null } }),
boxName: cur.name,
countText: `전체 ${curCount}`,
listLabel: `${cur.name} 메일 목록`,
items: items,
hasItems: items.length > 0,
noItems: items.length === 0,
emptyText: `${iga(cur.name)} 비어 있습니다`,
canEmpty: curIsEmptiable && curCount > 0,
emptyLabel: cur.id === 'trash' ? '휴지통 비우기' : '스팸메일함 비우기',
openEmpty: () => this.setState({ dialog: { kind: 'empty', id: cur.id } }),
dialogOpen: Boolean(d),
dialogTitle: dialog.title,
dialogRole: dialog.role,
dialogHasName: dialog.hasName,
dialogHasText: dialog.hasText,
dialogText: dialog.text,
dialogHasConfirm: dialog.hasConfirm,
confirmLabel: dialog.confirmLabel,
confirmBg: dialog.confirmBg,
cancelLabel: dialog.cancelLabel,
confirmDialog: dialog.confirm,
closeDialog: () => this.setState({ dialog: null }),
nameValue: d && d.kind === 'rename' && target ? target.name : '협력사',
nameClass: nameError ? 'field is-error' : 'field',
nameInvalid: nameError ? 'true' : 'false',
nameHelp: nameError ? '같은 이름의 메일함이 이미 있습니다.' : '64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.',
nameHelpColor: nameError ? '#B42318' : '#667085',
hasToast: Boolean(st.toast),
toastText: st.toast || '',
};
}
}
</script>
</body>
</html>
+84 -2
View File
@@ -1,13 +1,14 @@
# 웹메일 디자인 원본 (1~3단계)
# 웹메일 디자인 원본 (1~4단계)
확정된 디자인 캔버스의 원본이다. 화면 구현의 기준으로 쓴다.
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 21)
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-28, 버전 23)
- 화면: `Login`(로그인) · `Main`(분할 보기: 메일함·목록·본문) · `MainList`(목록 보기: 게시판형 표) · `MainListRead`(목록 보기에서 연 메일)
· `Compose`(메일 쓰기 — 서식 편집기) · `ComposeEditor`(메일 쓰기 · 편집기 상태: 포커스·툴바·메뉴·팝오버·인용·이미지 첨부)
· `States`(오류·빈 상태·피드백 모음)
· 2단계: `Settings`(설정 · 메일 서명) · `ComposeAssist`(메일 쓰기 · 임시 저장 · 받는 사람 자동 완성 · 서명) — 아래 "2단계"
· 3단계: `Search`(메일 검색 · 분할 보기) · `BulkSelect`(여러 통 선택 · 목록 보기) · `ListStates`(새 메일 · 검색 · 여러 통 처리 상태) — 아래 "3단계"
· 4단계: `SettingsAccount`(설정 · 비밀번호) · `SettingsVacation`(설정 · 부재중 자동 응답) · `MailboxManage`(메일함 관리 · 휴지통 비우기) · `MailActions4`(스팸 · 원문 · 오래 걸리는 처리) — 아래 "4단계"
- 파일 형식은 캔버스 전용(`<x-dc>` 마크업 + `DCLogic` 클래스)이다. 그대로 쓰지 말고 **구조·간격·색·문구의 기준**으로만 본다.
`Compose` 의 툴바·메뉴는 눌러 볼 수 있는 시안이다 — 버튼 상태만 바뀌고 본문 글자에 서식이 입혀지지는 않는다.
@@ -262,6 +263,52 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러
[영구 삭제](#B42318 채움) [취소] — 열리면 「취소」에 초점이 간다.
- 메일함을 바꾸거나 검색하면 선택을 푼다.
## 4단계 — 계정 · 메일 설정 (2026-10-28 확정 — `SettingsAccount` · `SettingsVacation` · `MailboxManage` · `MailActions4`)
API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함 관리" · "부재중 자동 응답" · "스팸 · 원문".
### 설정 메뉴
- 왼쪽 메뉴를 두 묶음으로: **계정** › 비밀번호 / **메일** › 서명 · 부재중 자동 응답. 묶음 이름은 12px #667085 굵게, 항목은 지금 서명 항목과 같은 모양(40 · 아이콘 16).
### 비밀번호
- 칸 셋(최대 폭 420): 지금 비밀번호 · 새 비밀번호 · 새 비밀번호 확인. 칸 높이 44, 앞의 두 칸 끝에 보기/숨기기 단추(44 · 눈 아이콘, `aria-pressed`).
- 새 비밀번호 아래 안내 "8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다." (12.5px #667085).
- 오류는 그 칸 아래 빨간 글(#B42318, 아이콘 alert-circle) · 칸 테두리도 빨강 — "지금 비밀번호가 올바르지 않습니다." · "새 비밀번호가 서로 다릅니다."
- [비밀번호 바꾸기] → "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다."(#067647), 세 칸을 비운다.
- 아래 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것과 메일 프로그램(IMAP)에 새 비밀번호를 다시 넣어야 하는 것.
### 부재중 자동 응답
- [x] 부재중 자동 응답 사용(44). 끄면 아래 칸을 흐리게(쓴 글은 남긴다).
- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준)."
- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "글자만 보냅니다 (서식 · 그림 없음). 서명은 붙지 않습니다.").
- [저장] → "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." / "끌 때까지 자동으로 답합니다." / 끄면 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다."
- 미리 보기 "보낸 사람이 받는 답장"(보낸 사람 · 제목 · 내용) + "같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙)."
- 켜져 있는 동안 메일 화면의 메일함 패널 아래(사용량 위)에 안내(바탕 #FFF7E6 · #7A5200, 높이 44 이상): "부재중 자동 응답 켜짐" / "11월 3일까지 · 설정" — 누르면 이 설정으로.
### 메일함 관리 (메일함 패널)
- 기본 메일함 아래에 "내 메일함"(12px #667085 굵게) 묶음 — 하위 메일함은 16px 씩 들여 쓴다(지금 규칙). 묶음 끝에 [+ 새 메일함](44).
- 각 메일함 행 오른쪽에 ⋯ 단추(32×40 — 별 칸과 같은 밀도 예외). 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다. 숫자(안 읽음)는 ⋯ 앞에 그대로.
- ⋯ 메뉴(항목 40): 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기(받은편지함 · 내 메일함) · 이름 바꾸기 · 지우기(빨강 — 내 메일함만) ·
휴지통 비우기 / 스팸메일함 비우기(빨강 — 그 메일함에서만).
- 대화상자(공용 모양): 새 메일함 · "‘프로젝트’ 안에 새 메일함" · 메일함 이름 바꾸기 — 이름 칸(44, 64자) + 안내 "64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다."
(같은 이름이면 그 자리에 빨강 "같은 이름의 메일함이 이미 있습니다."). 지우기 — "‘거래처’ 메일함을 지울까요?" "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다." [지우기](빨강 채움) [취소].
하위 메일함이 있으면 "‘프로젝트’ 메일함을 지울 수 없습니다" "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." [확인].
- 휴지통 · 스팸메일함 목록 머리 오른쪽에 [휴지통 비우기] / [스팸메일함 비우기](빨강 글자 · 44). 확인 "휴지통의 메일 21통을 모두 영구 삭제할까요?" "지운 메일은 되돌릴 수 없습니다." [영구 삭제] [취소].
- 비우기 · 지우기 대화상자는 열리면 「취소」에 초점이 간다.
### 스팸 · 원문 · 오래 걸리는 처리
- 읽기 화면 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고 · 삭제 · ⋯(더 보기 40×44) — ⋯ 메뉴에 "원문 내려받기 (.eml)". 스팸메일함에서는 「스팸 신고」 자리에 「스팸 아님」(#0A5F67 굵게, 받은편지함 아이콘).
- 여러 통 도구 줄: 이동 ▾ 과 삭제 사이에 「스팸 신고」(스팸메일함에서는 「스팸 아님」).
- 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 알림이 "받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통"(돌아가는 아이콘)으로 남았다가
끝나면 "받은편지함의 메일 1,234통을 읽음으로 표시했습니다." 처리하는 동안 다른 일을 해도 된다.
- 알림 문구: "메일 2통을 스팸메일함으로 옮겼습니다." [되돌리기] · "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." [되돌리기] ·
"‘협력사’ 메일함을 만들었습니다." · "‘거래처’ 메일함을 지웠습니다. 안의 메일 12통은 휴지통에 있습니다." · "휴지통을 비웠습니다 — 메일 21통." (비우기 · 지우기는 되돌리기 없음)
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -320,3 +367,38 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러
- 요청이 통째로 실패하면 고른 것을 그대로 둔다(다시 시도할 수 있게). 열어 둔 메일이 옮겨져 목록에서 빠지면 목록 주소로 바꾼다.
- 영구 삭제 확인은 공용 대화상자 폭(360)을 쓴다.
- 「되돌리기」는 이 화면이 알던 메일함 한 곳으로 되돌리고, 로그아웃 · 다른 계정 로그인 뒤에는 움직이지 않는다 (계약 2절 "알려진 한계").
### 4단계 구현하며 정한 보완 (2026-10-28 — 캔버스에는 아직 없다)
- **설정**: `/settings` 는 계정 › 비밀번호로 연다(앱 레일의 "설정").
- **비밀번호**
- 8자 미만이거나 지금 비밀번호와 같으면 새 비밀번호 아래 안내 글이 그대로 빨간 오류로 바뀐다.
- 서버의 거절은 칸 아래에만 보이고 알림은 띄우지 않는다 — 「지금 비밀번호가 올바르지 않습니다.」는 지금 비밀번호 칸, 엔진 규칙(`VAL_001`)은 새 비밀번호 칸.
- 보기 단추는 이름을 바꾸지 않고 눌림 상태(`aria-pressed`)로 알린다(ARIA 권고).
- 요청은 30초까지 기다린다(서버가 엔진을 두 번 부른다 — 계약 1절).
- **부재중 자동 응답**
- 날짜는 하나만 골라도 된다 — 시작만이면 그날부터 끌 때까지, 끝만이면 지금부터 그날까지.
- 꺼 둔 채 저장할 때 기간이 맞지 않으면(흐린 칸이라 고칠 수 없다) 날짜를 비워 저장한다 — 끄기는 늘 저장되게. 다시 열면 "끌 때까지 계속"이다.
- 저장 글은 올해가 아니면 연도를 붙인다("2027년 1월 3일(일)"). 날짜가 하나면 "…부터 자동으로 답합니다." · "…까지 자동으로 답합니다."
- 메일함 패널 안내: 끝나는 시각이 지나면 숨긴다. 아직 시작 전이면 "10월 30일 ~ 11월 3일 · 설정"(끝이 없으면 "10월 30일부터 · 설정"),
끝이 없으면 "끌 때까지 · 설정". 화면을 띄워 둔 채 시각이 지나도 1분 안에 다시 맞춘다.
- 바꾸고 저장하지 않은 채 떠나면 묻는다 — 제목 "저장하지 않은 자동 응답이 있습니다", 글 "이 화면을 나가면 바꾼 내용이 사라집니다.", [계속 고치기] [나가기].
저장하는 중에 떠났는데 서버가 거절하면 알림으로 알린다(저장 단추 옆 글이 보이지 않으므로).
- **메일함 관리**
- 할 일이 없는 메일함(보낸편지함, 안 읽은 메일이 없는 임시보관함, 빈 휴지통 등)에는 ⋯ 를 그리지 않는다. 빈 휴지통 · 스팸메일함에는 비우기 항목도 목록 머리 단추도 없다.
- 하위 메일함은 받은편지함 · 내 메일함 아래에만 만든다(계약은 보관함 아래도 받는다 — 다른 메일 프로그램이 만든 것을 그대로 보인다).
- 대화상자는 공용 폭 360(캔버스는 400). "지울 수 없습니다"의 [확인]은 단추가 하나라 주 단추 모양이다(캔버스는 보조 단추 — 세션 만료 대화상자와 같게).
- 이름을 바꾸지 않고 [바꾸기]를 누르면 요청 없이 닫는다. 이름에는 `/` 와 제어 문자(줄바꿈 등)를 쓸 수 없다.
- 분할 보기의 목록 머리(384)에서는 「휴지통 비우기」 · 「스팸메일함 비우기」가 아이콘만이다(40×44 — 이름은 툴팁). 보기 전환 · 새로고침과 함께 이름까지 넣으면 넘친다(3단계 도구 줄과 같은 규칙).
- ⋯ 메뉴는 보이는 영역 안에 다 들게 연다 — 패널 아래쪽 행에서는 위로 연다.
- 보던 메일함을 지우면 받은편지함으로 간다. 비우기 · 지우기가 끝나면 초점은 메일함 패널의 지금 메일함(지웠으면 받은편지함)으로 간다 — 누른 단추가 사라지므로.
- **오래 걸리는 처리**
- 하는 중의 글: "‘거래처’ 메일함을 지우는 중 · 12 / 40통" · "휴지통을 비우는 중 · 600 / 1,234통" (빈 메일함 지우기처럼 셀 메일이 없으면 수를 뺀다).
- 도중에 실패하면 "1,234통 가운데 600통까지 처리했습니다 — 다시 시도해 주세요."로 바뀐다(까닭은 오류 알림으로 함께). 하나도 못 했으면 오류 알림만 남는다.
- 다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — "받은편지함에 안 읽은 메일이 없습니다." · "휴지통이 이미 비어 있습니다.", 이미 없는 메일함 지우기는 지운 것으로 본다.
- 같은 메일함의 처리는 한 번에 하나다(다른 탭에서 같은 메일함을 처리하는 중이면 "요청이 너무 많습니다"). 진행 알림은 로그아웃 · 세션 만료 때 닫힌다.
- **휴지통을 비우는 동안은 메일함을 지울 수 없고, 메일함을 지우는 동안은 휴지통을 비울 수 없다** — 비우기는 그동안 휴지통에 들어온 메일도 지우므로,
지우는 메일함의 메일이 휴지통에 들어가자마자 영구 삭제되지 않게(계약 2절 "메일함 관리").
- 목록 머리 「비우기」의 확인은 연 그 메일함만 비운다 — 확인 창이 열린 채 다른 메일함으로 가면(뒤로 가기 등) 창을 닫는다.
- 서버가 처리 수 0 을 세 번 연달아 주면 멈추고 실패로 알린다(같은 요청을 끝없이 보내지 않게).
- **스팸**: 「되돌리기」는 원래 메일함으로 옮기며 표시를 반대로 바꾼다 — 원래 아무 표시도 없던 메일은 "스팸 아님" 표시가 남는다(계약 2절 "스팸 · 원문").
+224
View File
@@ -0,0 +1,224 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>설정 · 비밀번호</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.pw-box:focus-within{border-color:#0E7C86 !important;box-shadow:0 0 0 1px #0E7C86}
.pw-box.is-error{border-color:#B42318 !important}
.pw-box.is-error:focus-within{box-shadow:0 0 0 1px #B42318}
.pw-input{flex:1 1 auto;min-width:0;height:42px;border:0;outline:none;background:transparent;font:inherit;color:#171C26;padding:0 12px}
.ghost{background:transparent;color:#4A5363}
.ghost:hover{background:#F2F4F7;color:#171C26}
.menu-link{color:#344054}
.menu-link:hover{background:#EEF0F3}
</style>
</helmet>
<div style="min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
</a>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
<li>
<a href="Main.dc.html" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>메일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>캘린더</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span>파일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>
<span>ERP</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span>주소록</span>
</a>
</li>
</ul>
<a href="Settings.dc.html" aria-current="page" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>설정</span>
</a>
</nav>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">설정</span>
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" aria-label="통합 검색" placeholder="통합 검색" style="flex: 1 1 auto; min-width: 0; border: 0; background: transparent; font: inherit; color: #171C26">
</div>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</a>
</div>
</header>
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
<nav aria-label="설정 메뉴" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
<p style="margin: 4px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">계정</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
<span>비밀번호</span>
</a></li>
</ul>
<p style="margin: 14px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">메일</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="Settings.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>서명</span>
</a></li>
<li><a href="SettingsVacation.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><line x1="10" y1="14" x2="14" y2="18"></line><line x1="14" y1="14" x2="10" y2="18"></line></svg>
<span>부재중 자동 응답</span>
</a></li>
</ul>
</nav>
<main style="flex: 999 1 560px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
<div style="max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 4px">
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">비밀번호</h1>
<p style="margin: 0; color: #4A5363">웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.</p>
</div>
<form style="display: flex; flex-direction: column; gap: 18px; max-width: 420px; margin: 0">
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="pw-current" style="font-weight: 600">지금 비밀번호</label>
<div class="{{ currentBoxClass }}" style="display: flex; align-items: center; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
<input id="pw-current" class="pw-input" type="{{ currentType }}" autocomplete="current-password" value="{{ currentValue }}" aria-invalid="{{ currentInvalid }}" aria-describedby="pw-current-error">
<button type="button" class="ghost" onClick="{{ toggleCurrent }}" aria-label="{{ currentToggleLabel }}" aria-pressed="{{ currentShownStr }}" style="flex: 0 0 auto; width: 44px; height: 42px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 0 7px 7px 0; cursor: pointer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
</button>
</div>
<sc-if value="{{ showCurrentError }}" hint-placeholder-val="{{ false }}">
<p id="pw-current-error" style="margin: 0; display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: #B42318">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span>지금 비밀번호가 올바르지 않습니다.</span>
</p>
</sc-if>
</div>
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="pw-new" style="font-weight: 600">새 비밀번호</label>
<div class="pw-box" style="display: flex; align-items: center; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
<input id="pw-new" class="pw-input" type="{{ newType }}" autocomplete="new-password" value="{{ newValue }}" aria-describedby="pw-new-hint">
<button type="button" class="ghost" onClick="{{ toggleNew }}" aria-label="{{ newToggleLabel }}" aria-pressed="{{ newShownStr }}" style="flex: 0 0 auto; width: 44px; height: 42px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 0 7px 7px 0; cursor: pointer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
</button>
</div>
<p id="pw-new-hint" style="margin: 0; font-size: 12.5px; color: #667085">8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.</p>
</div>
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="pw-confirm" style="font-weight: 600">새 비밀번호 확인</label>
<div class="{{ confirmBoxClass }}" style="display: flex; align-items: center; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
<input id="pw-confirm" class="pw-input" type="{{ newType }}" autocomplete="new-password" value="{{ confirmValue }}" aria-invalid="{{ confirmInvalid }}" aria-describedby="pw-confirm-error">
</div>
<sc-if value="{{ showConfirmError }}" hint-placeholder-val="{{ false }}">
<p id="pw-confirm-error" style="margin: 0; display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: #B42318">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span>새 비밀번호가 서로 다릅니다.</span>
</p>
</sc-if>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 12px">
<button type="button" onClick="{{ submit }}" style="display: inline-flex; align-items: center; min-height: 44px; padding: 0 22px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer">비밀번호 바꾸기</button>
<sc-if value="{{ showDone }}" hint-placeholder-val="{{ false }}">
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; color: #067647; font-weight: 500">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span>비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.</span>
</span>
</sc-if>
</div>
</form>
<div style="display: flex; align-items: flex-start; gap: 10px; max-width: 620px; padding: 12px 14px; border-radius: 8px; background: #F3F8F8; color: #344054">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
<div style="display: flex; flex-direction: column; gap: 2px">
<span>바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.</span>
<span>휴대폰이나 PC 메일 프로그램(IMAP)에는 새 비밀번호를 다시 넣어 주세요.</span>
</div>
</div>
</div>
</main>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"variant":{"editor":"enum","options":["입력","지금 비밀번호 틀림","확인 불일치","바꿈"],"default":"입력"},"$preview":{"width":1440,"height":900}}'>
class Component extends DCLogic {
state = { showCurrent: false, showNew: false, submitted: null };
renderVals() {
const st = this.state;
const variant = st.submitted ?? this.props.variant ?? '입력';
const done = variant === '바꿈';
const currentWrong = variant === '지금 비밀번호 틀림';
const mismatch = variant === '확인 불일치';
const dots = (n) => '•'.repeat(n);
return {
currentType: st.showCurrent ? 'text' : 'password',
newType: st.showNew ? 'text' : 'password',
currentValue: done ? '' : (st.showCurrent ? 'old-pass-1234' : dots(13)),
newValue: done ? '' : (st.showNew ? 'Ara!2026-mail' : dots(13)),
confirmValue: done ? '' : (mismatch ? (st.showNew ? 'Ara!2026-mai' : dots(12)) : (st.showNew ? 'Ara!2026-mail' : dots(13))),
currentToggleLabel: st.showCurrent ? '지금 비밀번호 숨기기' : '지금 비밀번호 보기',
newToggleLabel: st.showNew ? '새 비밀번호 숨기기' : '새 비밀번호 보기',
currentShownStr: st.showCurrent ? 'true' : 'false',
newShownStr: st.showNew ? 'true' : 'false',
toggleCurrent: () => this.setState({ showCurrent: !st.showCurrent }),
toggleNew: () => this.setState({ showNew: !st.showNew }),
currentBoxClass: currentWrong ? 'pw-box is-error' : 'pw-box',
confirmBoxClass: mismatch ? 'pw-box is-error' : 'pw-box',
currentInvalid: currentWrong ? 'true' : 'false',
confirmInvalid: mismatch ? 'true' : 'false',
showCurrentError: currentWrong,
showConfirmError: mismatch,
showDone: done,
submit: () => this.setState({ submitted: '바꿈' }),
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,223 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>설정 · 부재중 자동 응답</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.pw-box:focus-within{border-color:#0E7C86 !important;box-shadow:0 0 0 1px #0E7C86}
.pw-box.is-error{border-color:#B42318 !important}
.pw-box.is-error:focus-within{box-shadow:0 0 0 1px #B42318}
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
.field:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
.field:disabled{background:#F8F9FB;color:#98A2B3}
.ghost{background:transparent;color:#4A5363}
.ghost:hover{background:#F2F4F7;color:#171C26}
.menu-link{color:#344054}
.menu-link:hover{background:#EEF0F3}
</style>
</helmet>
<div style="min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
</a>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
<li>
<a href="Main.dc.html" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>메일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>캘린더</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span>파일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>
<span>ERP</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span>주소록</span>
</a>
</li>
</ul>
<a href="Settings.dc.html" aria-current="page" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>설정</span>
</a>
</nav>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">설정</span>
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" aria-label="통합 검색" placeholder="통합 검색" style="flex: 1 1 auto; min-width: 0; border: 0; background: transparent; font: inherit; color: #171C26">
</div>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</a>
</div>
</header>
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
<nav aria-label="설정 메뉴" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
<p style="margin: 4px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">계정</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="SettingsAccount.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
<span>비밀번호</span>
</a></li>
</ul>
<p style="margin: 14px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">메일</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="Settings.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>서명</span>
</a></li>
<li><a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><line x1="10" y1="14" x2="14" y2="18"></line><line x1="14" y1="14" x2="10" y2="18"></line></svg>
<span>부재중 자동 응답</span>
</a></li>
</ul>
</nav>
<main style="flex: 999 1 560px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
<div style="max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 4px">
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">부재중 자동 응답</h1>
<p style="margin: 0; color: #4A5363">자리를 비운 동안 받은 메일에 정해 둔 글로 자동으로 답합니다. 켜 두면 메일 화면의 메일함 패널 아래에 알립니다.</p>
</div>
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
<input type="checkbox" checked="{{ on }}" onChange="{{ toggleOn }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span style="font-weight: 600">부재중 자동 응답 사용</span>
</label>
<fieldset disabled="{{ off }}" style="display: flex; flex-direction: column; gap: 0; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 4px; padding: 0; font-weight: 600; color: #171C26">기간</legend>
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
<input type="radio" name="vac-period" checked="{{ untilOff }}" onChange="{{ pickUntilOff }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span>끌 때까지 계속</span>
</label>
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
<input type="radio" name="vac-period" checked="{{ ranged }}" onChange="{{ pickRanged }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span>기간 정하기</span>
</label>
<sc-if value="{{ ranged }}" hint-placeholder-val="{{ true }}">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 4px 0 0 28px">
<label for="vac-from" style="font-size: 12.5px; color: #4A5363">시작</label>
<input id="vac-from" class="field" type="date" value="2026-10-30" style="height: 44px; padding: 0 10px">
<span aria-hidden="true" style="color: #667085">~</span>
<label for="vac-to" style="font-size: 12.5px; color: #4A5363">끝</label>
<input id="vac-to" class="field" type="date" value="2026-11-03" style="height: 44px; padding: 0 10px">
</div>
<p style="margin: 6px 0 0 28px; font-size: 12.5px; color: #667085">시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준).</p>
</sc-if>
</fieldset>
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="vac-subject" style="font-weight: 600">제목</label>
<input id="vac-subject" class="field" type="text" value="부재중 안내 — 11월 4일 복귀" disabled="{{ off }}" style="height: 44px; padding: 0 12px; max-width: 520px">
</div>
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="vac-body" style="font-weight: 600">내용</label>
<textarea id="vac-body" class="field" rows="6" disabled="{{ off }}" style="padding: 10px 12px; line-height: 1.7; resize: vertical; white-space: pre-line">안녕하세요, 김아라입니다.
10월 30일부터 11월 3일까지 휴가라 메일 확인이 늦어집니다.
급한 일은 인프라팀(infra@arasystem.it.kr)으로 연락해 주세요.</textarea>
<p style="margin: 0; font-size: 12.5px; color: #667085">글자만 보냅니다 (서식 · 그림 없음). 서명은 붙지 않습니다.</p>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 12px">
<button type="button" onClick="{{ save }}" style="display: inline-flex; align-items: center; min-height: 44px; padding: 0 22px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer">저장</button>
<sc-if value="{{ saved }}" hint-placeholder-val="{{ false }}">
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; color: #067647; font-weight: 500">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span>{{ savedText }}</span>
</span>
</sc-if>
</div>
<hr style="width: 100%; height: 1px; margin: 0; border: 0; background: #EEF0F3">
<section aria-labelledby="vac-preview-title" style="display: flex; flex-direction: column; gap: 12px">
<h2 id="vac-preview-title" style="margin: 0; font-size: 14px; font-weight: 700">보낸 사람이 받는 답장</h2>
<div style="display: flex; flex-direction: column; gap: 10px; box-sizing: border-box; padding: 16px 20px 20px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF">
<dl style="margin: 0; display: grid; grid-template-columns: 72px minmax(0, 1fr); row-gap: 2px; font-size: 12.5px">
<dt style="color: #667085">보낸 사람</dt><dd style="margin: 0">김아라 &lt;ara.kim@arasystem.it.kr&gt;</dd>
<dt style="color: #667085">제목</dt><dd style="margin: 0">부재중 안내 — 11월 4일 복귀</dd>
</dl>
<div style="white-space: pre-line; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">안녕하세요, 김아라입니다.
10월 30일부터 11월 3일까지 휴가라 메일 확인이 늦어집니다.
급한 일은 인프라팀(infra@arasystem.it.kr)으로 연락해 주세요.</div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">{{ previewNote }}</p>
</section>
</div>
</main>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":900}}'>
class Component extends DCLogic {
state = { on: true, ranged: true, saved: false };
renderVals() {
const st = this.state;
return {
on: st.on,
off: !st.on,
ranged: st.ranged,
untilOff: !st.ranged,
toggleOn: () => this.setState({ on: !st.on, saved: false }),
pickRanged: () => this.setState({ ranged: true, saved: false }),
pickUntilOff: () => this.setState({ ranged: false, saved: false }),
save: () => this.setState({ saved: true }),
saved: st.saved,
savedText: !st.on
? '저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.'
: st.ranged
? '저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다.'
: '저장했습니다. 끌 때까지 자동으로 답합니다.',
previewNote: st.on ? '받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).' : '꺼져 있습니다 — 자동으로 답하지 않습니다.',
};
}
}
</script>
</body>
</html>
+8 -3
View File
@@ -16,13 +16,18 @@
"ComposeAssist.dc.html": { "x": 1520, "y": 2480, "w": 1440, "h": 1400, "title": "메일 쓰기 · 임시 저장 · 자동 완성 · 서명" },
"Search.dc.html": { "x": 0, "y": 4480, "w": 1440, "h": 900, "title": "메일 검색 · 분할 보기", "expand": "fill", "is_interactive": true },
"BulkSelect.dc.html": { "x": 1520, "y": 4480, "w": 1440, "h": 900, "title": "여러 통 선택 · 목록 보기", "expand": "fill", "is_interactive": true },
"ListStates.dc.html": { "x": 3040, "y": 4480, "w": 1440, "h": 1560, "title": "새 메일 · 검색 · 여러 통 처리 상태" }
"ListStates.dc.html": { "x": 3040, "y": 4480, "w": 1440, "h": 1560, "title": "새 메일 · 검색 · 여러 통 처리 상태" },
"SettingsAccount.dc.html": { "x": 0, "y": 6640, "w": 1440, "h": 900, "title": "설정 · 비밀번호", "expand": "fill", "is_interactive": true },
"SettingsVacation.dc.html": { "x": 1520, "y": 6640, "w": 1440, "h": 1060, "title": "설정 · 부재중 자동 응답", "expand": "fill", "is_interactive": true },
"MailboxManage.dc.html": { "x": 3040, "y": 6640, "w": 1440, "h": 900, "title": "메일함 관리 · 휴지통 비우기", "expand": "fill", "is_interactive": true },
"MailActions4.dc.html": { "x": 4560, "y": 6640, "w": 1440, "h": 1320, "title": "스팸 · 원문 · 오래 걸리는 처리 상태" }
},
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html"],
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html"],
"notes": {
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
"phase3": { "x": 0, "y": 4180, "text": "3단계 화면 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정)", "kind": "title1", "maxW": 4480 }
"phase3": { "x": 0, "y": 4180, "text": "3단계 화면 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정)", "kind": "title1", "maxW": 4480 },
"phase4": { "x": 0, "y": 6340, "text": "4단계 화면 — 계정 · 메일 설정 (비밀번호 · 부재중 응답 · 메일함 관리 · 스팸 · 원문) — 2026-10-28 확정", "kind": "title1", "maxW": 6000 }
},
"designSystems": []
}
+115 -6
View File
@@ -8,7 +8,7 @@
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `SYS_001`
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `SYS_001`
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
@@ -43,8 +43,8 @@
| --- | --- |
| 계정 없음 · 비밀번호 틀림 · 사내 도메인이 아닌 주소 · 메일 엔진 관리자 계정(웹메일 로그인 대상이 아니다) (구분하지 않는다) | 401 `AUTH_004` |
| 형식 오류 | 400 `VAL_001` |
| 시도 과다 — 같은 IP 에서 1분에 **실패** 20회, 같은 주소로 15분에 **실패** 10회 (성공한 로그인은 세지 않는다 — 사무실처럼 여러 직원이 한 IP 를 쓰는 환경 때문). 이와 별도로 같은 IP 의 로그인 **요청**은 1분에 60회까지. "같은 IP" 는 IPv6 를 **/64 단위**로 묶는다(IPv4-mapped 주소는 IPv4 로). 사내 도메인이 아닌 주소는 IP 한도에만 센다 | 429 `REQ_001` |
| 같은 주소의 로그인이 아직 처리 중 (버튼을 두 번 누른 경우 등) | 429 `REQ_001` |
| 시도 과다 — 같은 IP 에서 1분에 **실패** 20회, 같은 주소로 15분에 **실패** 10회 (성공한 로그인은 세지 않는다 — 사무실처럼 여러 직원이 한 IP 를 쓰는 환경 때문). 이와 별도로 같은 IP 의 로그인 **요청**은 1분에 60회까지. "같은 IP" 는 IPv6 를 **/64 단위**로 묶는다(IPv4-mapped 주소는 IPv4 로). 사내 도메인이 아닌 주소는 IP 한도에만 센다. 비밀번호 변경(`PUT /api/auth/password`)의 지금 비밀번호 틀림도 같은 한도로 함께 센다 | 429 `REQ_001` |
| 같은 주소의 로그인 · 비밀번호 변경이 아직 처리 중 (버튼을 두 번 누른 경우 등) | 429 `REQ_001` |
| 대량 공격으로 서버가 기억하는 시도 기록이 상한에 닿았고 밀어낼 수 있는 기록이 없음 — 새 IP·주소의 로그인 (잠금 기록을 지키려고 막는 쪽을 택한다) | 429 `REQ_001` |
| 메일 엔진 장애 | 503 `MAIL_001` |
@@ -76,6 +76,34 @@
항상 200 (`data: null`). 두 쿠키를 지우고 **서버의 세션을 폐기**한다.
### `PUT /api/auth/password` — 4단계
```jsonc
// 요청 (로그인 필요)
{ "currentPassword": "…", "newPassword": "…" }
// 200 (data: null)
```
| 실패 | 코드 |
| --- | --- |
| 지금 비밀번호가 틀림 | 400 `AUTH_005` — 세션은 그대로다. 401 이 아닌 까닭: 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 |
| 새 비밀번호가 8자 미만 · 128자 초과 · 지금 비밀번호와 같음, 엔진 규칙에 어긋남 · 지금 비밀번호가 비었거나 256자 초과(로그인과 같은 한도) — 길이는 JS 문자열 길이 | 400 `VAL_001` |
| 지금 비밀번호 틀림이 잦음 — 로그인 실패와 **같은 한도**(같은 IP 1분 20회 · 같은 주소 15분 10회)를 함께 센다. 같은 주소의 로그인 · 변경이 처리 중 | 429 `REQ_001` |
| 웹메일로 다루지 않는 계정(메일 엔진 관리자 · 게이트웨이 · 막은 계정 — 엔진에 묻지 않는다) | 401 `AUTH_001` |
| 메일 엔진 장애 — 바꾸는 요청의 결과를 알 수 없는 실패(시간 초과 · 연결 끊김 · 5xx)도 | 503 `MAIL_001` — 결과를 모를 때는 바뀌었을 수 있으므로 **다른 세션을 끊어 둔다** |
- 서버는 메일 엔진에 **그 직원 본인의 자격으로**(지금 비밀번호) 본인 비밀번호 변경(Stalwart `x:AccountPassword/set` 에 `currentSecret` · `secret`)을 보낸다 —
게이트웨이 대리 접근이나 관리자 권한을 쓰지 않는다. 엔진은 지금 비밀번호 없이는 바꾸지 않고 8자 미만을 거절한다 (2026-10-28 개발 엔진에서 확인).
세션은 같은 값으로 열렸는데 엔진이 바꾸기를 거절하면(`forbidden` · `currentSecret` 의 `invalidProperties` · 바꾸기 요청의 401 — 앱 비밀번호를 넣은 경우 · 역할의 권한 설정)
`AUTH_005` 로 답하되 실패 횟수로 세지 않고 세션도 끊지 않는다(추측이 아니라서). 결과를 알 수 없는 실패도 실패 횟수로 세지 않는다.
- 바뀌면 그 직원의 **다른 로그인 세션을 모두 끊는다** — 이 요청의 리프레시 쿠키(Path=`/api/auth` 라 이 요청에도 온다)가 가리키는 세션만 남긴다.
쿠키가 없거나 다른 직원 것이면 이 세션도 끊긴다(다음 갱신에서 로그인 화면으로). 끊긴 기기의 액세스 토큰은 만료(최대 1시간)까지 쓰인다(위 세션 규칙).
휴대폰 · PC 메일 프로그램(IMAP)은 새 비밀번호를 다시 넣어야 한다.
- 비밀번호는 바뀌었는데 세션 끊기가 실패하면 200 으로 답하고 오류 로그를 남긴다 — 실패로 답하면 사용자가 옛 비밀번호로 다시 시도하기 때문이다.
- 비밀번호는 로그 · 응답에 남기지 않는다. 바꾼 일은 info 로그에 주소(가린 것)만 남긴다.
- **클라이언트**: 같은 규칙(8자 이상 · 지금과 다름 · 확인 칸 일치)을 먼저 보고, `AUTH_005` 는 지금 비밀번호 칸 아래에 사전 문구로 보인다. 성공하면 세 칸을 비운다.
요청 한도는 30초다 — 서버가 엔진을 두 번 부르고 세션을 끊으므로, 기본 한도(10초)로는 서버가 바꾼 뒤 화면만 실패로 볼 수 있다.
### 클라이언트 처리 규칙
- 보호 API 가 401 `AUTH_001` 을 주면 **한 번만** `/auth/refresh` 를 시도하고, 성공하면 원래 요청을 다시 보낸다.
@@ -175,17 +203,25 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
| --- | --- | --- | --- |
| `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` |
| `POST /api/mailboxes` | `{ name, parentId? }` (아래 "메일함 관리") | 201 `Mailbox` | 400 `VAL_001`, 404 `RES_001`(없는 parentId), 409 `MAIL_009`(같은 자리에 같은 이름), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}` | `{ name }` | 200 `Mailbox` | 400 `VAL_001`(기본 메일함), 404 `RES_001`, 409 `MAIL_009`, 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacation` (아래 "부재중 자동 응답") | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체) | 200 `MailVacation` | 400 `VAL_001`, 503 `MAIL_001` |
| `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` |
| `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` |
| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` — `mailboxId` 와 `q` 중 하나는 있어야 한다(`q` 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` |
| `GET /api/messages/{id}` | — | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 404 `RES_001` |
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId? }` (하나 이상) | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` |
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` |
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) |
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `{ id }` (보낸편지함의 메일 id) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` |
| `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` |
| `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` |
| `GET /api/messages/{id}/source` | — | 받은 그대로의 메일 `message/rfc822` (표준 래퍼 없음, `Content-Disposition: attachment; filename="<제목>.eml"`) — 4단계 | 404 `RES_001`, 503 `MAIL_001` |
| `GET /api/drafts/{id}` | — | 200 `Draft` (아래 "임시 저장") | 404 `RES_001`(없음·초안이 아님), 503 `MAIL_001` |
| `POST /api/drafts` | 초안 본문 | 201 `DraftSaved` | 400 `VAL_001`·`MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`FILE_001`, 413 `FILE_002`, 503 `MAIL_001` |
| `PUT /api/drafts/{id}` | 초안 본문 (전체 교체) | 200 `DraftSaved` — **새 id** | 위와 같음 + 404 `RES_001`(초안이 아닌 id — 지우지 않음) + 409 `MAIL_008`(다른 곳에서 이미 바뀜 — 저장 안 함) |
@@ -535,8 +571,81 @@ interface MessageDeletionResult {
- `failedIds` · `skippedIds` 가 있으면 알림에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다"를 덧붙인다.
- 메일함을 바꾸거나 검색하면 선택을 푼다. 새 메일 확인으로 목록을 맞출 때는 남아 있는 메일의 선택을 그대로 둔다.
### 메일함 관리 (`/api/mailboxes`) — 4단계
```ts
interface MailboxCreate { name: string; parentId?: string | null } // parentId 없음 = 맨 위
interface MailboxRename { name: string }
interface MailboxDeletion {
deleted: boolean // true = 지웠다. false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다
processed: number // 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용)
movedToTrash: number // 그중 휴지통으로 옮긴 수 (끝난 알림의 "안의 메일 N통은 휴지통에 있습니다")
remaining: number // 아직 남은 메일 수
}
interface MailboxBulkResult {
processed: number // 이번 요청에서 읽음으로 바꾼 · 지운 메일 수
remaining: number // 남은 수 — 0 이 될 때까지 같은 요청을 다시 보낸다
}
```
- **이름**: 앞뒤 공백을 뺀 1~64자(JS 문자열 길이), `/` 는 쓰지 않는다(경로 구분자로 읽는 메일 프로그램이 있다). 제어 문자도 쓰지 않는다 —
C0(U+0000–U+001F, 탭 · 줄바꿈 포함) · DEL(U+007F) · C1(U+0080–U+009F). 그 밖의 문자(NBSP · 이모지 등)는 받는다.
같은 상위 안에 같은 이름은 409 `MAIL_009` (엔진이 `alreadyExists` 로 거절한다 — 2026-10-28 확인). 지금 이름으로 바꾸면 엔진에 묻지 않고 그대로 돌려준다.
만든 메일함은 구독 상태(`isSubscribed`)라 IMAP 메일 프로그램에도 보인다.
- **만들 수 있는 자리**: 맨 위, 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래. 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 400 `VAL_001`.
웹 화면은 받은편지함 · 내 메일함 아래만 메뉴에 둔다(보관함 아래는 다른 메일 프로그램이 만든 것을 그대로 보인다).
- **기본 메일함**(역할이 있는 메일함)은 이름을 바꾸거나 지울 수 없다 — 400 `VAL_001`. 화면은 메뉴에 그 항목을 두지 않는다.
- **지우기**: 하위 메일함이 있으면 400 `VAL_001`(화면이 먼저 막는다). 안의 메일은 **휴지통으로 옮긴 뒤**(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다)
빈 메일함을 지운다 — 엔진은 메일이 든 메일함을 `mailboxHasEmail` 로 거절하므로(`onDestroyRemoveEmails: false` — 메일을 영구 삭제하는 옵션은 쓰지 않는다) 그사이 들어온 메일도 남지 않는다.
메일이 많으면 한 요청에 다 옮기지 못한다 — `deleted: false` 면 클라이언트가 같은 요청을 다시 보낸다.
- **전체 읽음**(`PATCH /api/mailboxes/{id}/messages {isRead:true}`): 안 읽은 메일을 500통씩 읽음으로 바꾼다. **비우기**(`DELETE /api/mailboxes/{id}/messages`): 휴지통 · 스팸메일함만,
500통씩 확인한 상태 그대로일 때만(`ifInState`) 영구 삭제한다 — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다(`POST /api/message-deletions` 와 같은 규칙).
- 오래 걸리는 처리(전체 읽음 · 비우기 · 메일함 지우기)는 한 요청을 25초 안에 끝내고(엔진 호출마다 남은 시간만큼만 기다린다) `remaining` 을 돌려준다.
클라이언트는 0(또는 `deleted: true`)이 될 때까지 다시 보내며 진행을 알림으로 보인다("…하는 중 · 600 / 1,234통"). 도중에 실패하면 거기까지 처리한 수를 알리고, 다시 하면 남은 것만 처리한다.
- 같은 직원 · 같은 메일함의 처리는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(다른 탭에서 같은 메일함을 처리할 때). 다른 메일함끼리는 함께 된다.
- 한 요청의 상한 25초는 메일함 목록을 읽기 전부터 잰다(엔진의 세션 조회 포함). 다음 500통은 남은 시간이 5초보다 많을 때만 시작한다.
- 멈추는 경우 — 진행한 것이 있으면 거기까지 200(`remaining` > 0 · `deleted: false`), 하나도 처리하지 못했으면 503 `MAIL_001`(클라이언트가 끝없이 다시 보내지 않게):
아무것도 바뀌지 않는 차례(확인하는 사이 계속 바뀜 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 이어서 3번, 남은 시간 때문에 엔진 호출이 끊김.
엔진이 한 차례를 모두 거절하면 진행한 것이 없을 때 그 거절의 코드(권한 없음 403 `AUTH_003` 등)다.
- **비우기는 차례마다 그 시점의 메일함을 다시 읽는다** — 비우는 동안 들어온 메일(새 스팸, 다른 탭에서 휴지통으로 옮긴 메일)도 지운다(흔한 메일 프로그램과 같다).
그래서 화면은 휴지통 비우기와 메일함 지우기(안의 메일이 휴지통으로 간다)를 함께 돌리지 않는다.
- **클라이언트**: 메일함 패널의 각 메일함 ⋯ 메뉴 — 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기 · 이름 바꾸기 · 지우기(내 메일함만) · 휴지통 / 스팸메일함 비우기.
휴지통 · 스팸메일함 목록 머리에도 「비우기」. 비우기 · 지우기는 확인 대화상자(처음 초점 「취소」) 뒤에만 하고, 확인한 그 메일함만 처리한다.
다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — 모두 읽음 · 비우기의 0통은 "…에 안 읽은 메일이 없습니다" · "…이 이미 비어 있습니다", 지우기의 404 `RES_001` 은 지운 것으로 본다.
메일함 지우기의 진행은 `processed` 로 센다(`movedToTrash` 는 휴지통으로 간 것만이라 다른 메일함에도 든 메일만 남으면 늘지 않는다).
### 부재중 자동 응답 (`/api/mail-vacation`) — 4단계
```ts
interface MailVacation {
isEnabled: boolean
fromDate: string | null // ISO 8601 UTC — null 이면 켠 때부터
toDate: string | null // null 이면 끌 때까지
subject: string // 200자까지 — 켤 때(isEnabled true)는 공백이 아닌 글자 1자 이상. 제어 문자(메일함 이름과 같은 범위 — 탭 · 줄바꿈 포함)는 쓰지 않는다(한 줄짜리 머리글)
textBody: string // 4,000자까지 — 켤 때는 공백이 아닌 글자 1자 이상. 글자만(서식 없음) — 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
}
```
- 한 번도 설정하지 않은 계정은 `{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" }` 로 온다(엔진의 null 은 빈 문자열로). 끈 채로는 빈 제목 · 내용도 저장된다.
제목 · 내용은 쓴 그대로 저장한다 — "공백이 아닌 글자" 검사만 앞뒤 공백을 뺀 값(JS `trim()` — NBSP · U+3000 포함)으로 한다. 제어 문자 검사는 꺼 둔 채로도 한다.
- 엔진의 JMAP `VacationResponse`(singleton)를 그대로 쓴다 — `htmlBody` 는 비운다(null). 저장은 전체 교체다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
- `fromDate` · `toDate` 는 `Z` 로 끝나는 UTC 시각이고, 두 키는 늘 보낸다(값은 null 가능). 엔진에는 초 단위로 저장한다(소수 초는 버린다).
둘 다 있으면 버린 값으로 `fromDate < toDate`(아니면 400 `VAL_001`). 화면은 날짜만 고르고, 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다.
- 같은 사람에게 매번 답하지는 않는다 — JMAP 규격(RFC 8621 8절)이 "보낸 사람마다 적당한 기간에 한 번" 을 요구하고 엔진이 따른다.
- **클라이언트**: 설정 › 메일 › 부재중 자동 응답. 켜져 있으면 메일 화면의 메일함 패널 아래(사용량 위)에 "부재중 자동 응답 켜짐 · 11월 3일까지"(누르면 설정)를 보인다 —
메일 화면에 들어올 때 한 번 받고, 시간이 지나면 다시 계산한다(끝나는 시각이 지나면 숨기고, 시작 전이면 "10월 30일 ~ 11월 3일 · 설정").
### 스팸 · 원문 — 4단계
- **스팸 신고 · 스팸 아님**: `PATCH /api/messages/{id}` · `PATCH /api/messages` 의 `isJunk` — `true` 면 키워드 `$junk` 를 붙이고 `$notjunk` 를 뗀다, `false` 면 반대(RFC 8621 4.1.1).
화면은 이동과 함께 보낸다 — 스팸 신고 `{ mailboxId: <스팸메일함>, isJunk: true }`, 스팸 아님 `{ mailboxId: <받은편지함>, isJunk: false }`. 엔진의 스팸 학습에 쓰이는지는 엔진 몫이다.
「되돌리기」는 원래 메일함으로 옮기며 보낸 값의 반대를 보낸다 — 표시를 지우는 값은 두지 않아서, 원래 아무 표시도 없던 메일도 되돌린 뒤에는 `$notjunk`(스팸 아님)가 남는다.
- **원문 내려받기**: `GET /api/messages/{id}/source` — 엔진의 원본(blob)을 그대로 흘려보낸다(`message/rfc822`, `Cache-Control: private, no-store`). 파일 이름은 제목(없으면 "메일")에
`.eml` — 다운로드 헤더는 첨부와 같은 규칙(`shared/utils/content-disposition.ts`). 화면은 첨부 내려받기처럼 이동 전에 세션을 확인한다(`confirmSession()`).
### 이 단계에서 하지 않는 것
실시간 푸시(SSE · 데스크톱 알림 · 소리), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
불러오지 않은 메일까지 한꺼번에(메일함 전체 읽음 표시 · 휴지통 비우기), 끌어다 놓아 옮기기.
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, 인쇄.