docs: 3단계(새 메일·검색·여러 통 처리) 계약과 디자인

캔버스 버전 21 로 확정한 3단계 화면(Search · BulkSelect · ListStates)과 계약 2절의 새 절(새 메일 확인 · 검색 · 여러 통 한꺼번에).
개발 엔진(Stalwart 0.16.27)에서 직접 확인한 검색 한계 — 낱말이 통째로 같을 때만 맞고 제목 맨 앞 말머리는 색인 밖이다 — 와
그 보완(조사 붙인 형태를 함께 찾기), 휴지통 전용 영구 삭제의 조건(ifInState)을 함께 적었다.
웹 화면은 DELETE /api/messages/{id} 를 쓰지 않는다 — 다른 창이 휴지통에 넣은 메일을 확인 없이 영구 삭제하지 않게.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 23:56:09 +09:00
parent 419b2009ab
commit 7585b3bfac
6 changed files with 1321 additions and 10 deletions
+449
View File
@@ -0,0 +1,449 @@
<!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}
.ml-grid{display:grid;grid-template-columns:32px 32px 168px minmax(0,1fr) 84px 64px;column-gap:12px;align-items:center}
.ml-inner{min-width:440px}
.ml-row{position:relative}
.ml-row:hover{background:#F3F8F8}
.ml-row:focus-within{background:#E3F2F3}
.ml-row.is-sel{background:#E8F4F5}
.ml-row.is-sel:hover{background:#DDEFF0}
.ml-link::after{content:"";position:absolute;inset:0}
.ml-ctl{position:relative;z-index:1}
.ghost{background:transparent;color:#344054}
.ghost:hover{background:#F2F4F7;color:#171C26}
.danger{background:transparent;color:#B42318}
.danger:hover{background:#FEF3F2;color:#912018}
.menu-item:hover{background:#F2F4F7}
.sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
@media (max-width:1099px){.ml-grid{grid-template-columns:32px 32px 168px minmax(0,1fr) 84px}.ml-size{display:none}.tb-label{display:none}}
@media (max-width:599px){.ml-grid{grid-template-columns:32px 32px 112px minmax(0,1fr) 72px}}
</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>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ boxes }}" as="b" hint-placeholder-count="5">
<li>
<button type="button" onClick="{{ b.pick }}" aria-current="{{ b.current }}" style="width: 100%; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 7px; background: {{ b.bg }}; 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">{{ 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>
</li>
</sc-for>
</ul>
<div style="margin-top: auto; 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>
</nav>
<main style="flex: 999 1 560px; min-width: 0; display: flex; flex-direction: column">
<section aria-labelledby="list-title" style="display: flex; flex-direction: column">
<sc-if value="{{ noPick }}" hint-placeholder-val="{{ false }}">
<div style="display: flex; align-items: center; gap: 8px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 20px; 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>
<div style="margin-left: auto; display: inline-flex; gap: 2px; padding: 2px; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
<a href="Main.dc.html" aria-label="분할 보기" style="display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 0 8px; border-radius: 6px; color: #344054; font-size: 12px; font-weight: 500; text-decoration: none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" ry="2"></rect><line x1="10" y1="4" x2="10" y2="20"></line></svg>
<span>분할</span>
</a>
<button type="button" aria-pressed="true" aria-label="목록 보기" style="display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 0 8px; border: 0; border-radius: 6px; background: #E3F2F3; color: #0A5F67; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="9" y1="6" x2="20" y2="6"></line><line x1="9" y1="12" x2="20" y2="12"></line><line x1="9" y1="18" x2="20" y2="18"></line><line x1="4" y1="6" x2="4.01" y2="6"></line><line x1="4" y1="12" x2="4.01" y2="12"></line><line x1="4" y1="18" x2="4.01" y2="18"></line></svg>
<span>목록</span>
</button>
</div>
<button type="button" aria-label="새로고침" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #4A5363; 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"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
</button>
</div>
</sc-if>
<sc-if value="{{ hasPick }}" hint-placeholder-val="{{ true }}">
<div role="toolbar" aria-label="고른 메일" style="position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 20px; border-bottom: 1px solid #E6E8EC; background: #F3F8F8">
<h2 id="list-title" style="margin: 0 10px 0 0; font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums">{{ pickText }}</h2>
<button type="button" class="ghost" onClick="{{ markRead }}" title="읽음 표시" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; min-width: 40px; justify-content: center; 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"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z"></path><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"></path></svg>
<span class="tb-label">읽음 표시</span>
</button>
<button type="button" class="ghost" onClick="{{ markUnread }}" title="안 읽음 표시" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; min-width: 40px; justify-content: center; 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"><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 class="tb-label">안 읽음 표시</span>
</button>
<button type="button" class="ghost" onClick="{{ flagAll }}" title="{{ flagLabel }}" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; min-width: 40px; justify-content: center; 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="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
<span class="tb-label">{{ flagLabel }}</span>
</button>
<span aria-hidden="true" style="width: 1px; height: 20px; margin: 0 6px; background: #D0D5DD"></span>
<div style="position: relative">
<button type="button" class="ghost" onClick="{{ toggleMenu }}" aria-haspopup="menu" aria-expanded="{{ menuOpenStr }}" title="이동" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; min-width: 40px; justify-content: center; padding: 0 8px 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"><path d="M2 9V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-1.5"></path><path d="M2 13h10"></path><path d="m9 16 3-3-3-3"></path></svg>
<span class="tb-label">이동</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>
<sc-if value="{{ menuOpen }}" hint-placeholder-val="{{ false }}">
<div role="menu" aria-label="옮길 메일함" style="position: absolute; top: 46px; left: 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)">
<sc-for list="{{ targets }}" as="t" hint-placeholder-count="3">
<button type="button" role="menuitem" class="menu-item" onClick="{{ t.move }}" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; cursor: pointer">{{ t.name }}</button>
</sc-for>
</div>
</sc-if>
</div>
<button type="button" class="danger" onClick="{{ deletePicked }}" title="{{ deleteLabel }}" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; min-width: 40px; justify-content: center; 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 class="tb-label">{{ deleteLabel }}</span>
</button>
<button type="button" class="ghost" onClick="{{ clearPick }}" title="선택 해제 (Esc)" style="margin-left: auto; 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.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
<span>선택 해제</span>
</button>
</div>
</sc-if>
<sc-if value="{{ hasItems }}" hint-placeholder-val="{{ true }}">
<div style="overflow-x: auto">
<div class="ml-inner">
<div class="ml-grid" style="height: 32px; box-sizing: border-box; padding: 0 8px 0 20px; background: #FBFBFC; border-bottom: 1px solid #E6E8EC; font-size: 12px; font-weight: 600; color: #667085">
<button type="button" role="checkbox" aria-checked="{{ allChecked }}" aria-label="불러온 메일 모두 고르기" onClick="{{ toggleAll }}" style="width: 32px; height: 31px; display: flex; align-items: center; justify-content: flex-start; padding: 0; border: 0; background: transparent; cursor: pointer">
<sc-if value="{{ allNone }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="1" y="1" width="14" height="14" rx="3.5" fill="#FFFFFF" stroke="#8A94A3" stroke-width="1.5"></rect></svg></sc-if>
<sc-if value="{{ allSome }}" hint-placeholder-val="{{ true }}"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="0.25" y="0.25" width="15.5" height="15.5" rx="4" fill="#0E7C86"></rect><line x1="4.5" y1="8" x2="11.5" y2="8" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"></line></svg></sc-if>
<sc-if value="{{ allFull }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="0.25" y="0.25" width="15.5" height="15.5" rx="4" fill="#0E7C86"></rect><polyline points="4.2 8.3 6.9 10.8 11.8 5.4" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></polyline></svg></sc-if>
</button>
<span aria-hidden="true" style="display: flex; align-items: center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
</span>
<span aria-hidden="true">{{ whoHeader }}</span>
<span aria-hidden="true">제목</span>
<span aria-hidden="true" style="text-align: right">날짜</span>
<span aria-hidden="true" class="ml-size" style="text-align: right">크기</span>
</div>
<ul aria-label="{{ listLabel }}" style="list-style: none; margin: 0; padding: 0">
<sc-for list="{{ items }}" as="item" hint-placeholder-count="15">
<li class="{{ item.rowClass }}" style="height: 38px; box-sizing: border-box; padding: 0 8px 0 20px; border-bottom: 1px solid #EEF0F3">
<button class="ml-ctl" type="button" role="checkbox" aria-checked="{{ item.pickedStr }}" aria-label="{{ item.pickLabel }}" onClick="{{ item.togglePick }}" style="width: 32px; height: 37px; display: flex; align-items: center; justify-content: flex-start; padding: 0; border: 0; background: transparent; cursor: pointer">
<sc-if value="{{ item.notPicked }}" hint-placeholder-val="{{ true }}"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="1" y="1" width="14" height="14" rx="3.5" fill="#FFFFFF" stroke="#8A94A3" stroke-width="1.5"></rect></svg></sc-if>
<sc-if value="{{ item.picked }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="0.25" y="0.25" width="15.5" height="15.5" rx="4" fill="#0E7C86"></rect><polyline points="4.2 8.3 6.9 10.8 11.8 5.4" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></polyline></svg></sc-if>
</button>
<button class="ml-ctl" type="button" onClick="{{ item.toggleStar }}" aria-label="{{ item.starLabel }}" aria-pressed="{{ item.starredStr }}" style="width: 32px; height: 37px; display: flex; align-items: center; justify-content: flex-start; padding: 0; border: 0; border-radius: 4px; background: transparent; color: {{ item.starColor }}; cursor: pointer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="{{ item.starFill }}" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
</button>
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.fw }}; color: {{ item.whoColor }}">
<sc-if value="{{ item.unread }}" hint-placeholder-val="{{ false }}"><span class="sr">안 읽음, </span></sc-if>
<span class="sr">{{ whoHeader }} </span>{{ item.who }}
</span>
<span style="min-width: 0; display: flex; align-items: center; gap: 6px">
<a class="ml-link" href="MainListRead.dc.html" style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.subFw }}; color: #171C26; text-decoration: none">{{ item.subject }}</a>
<sc-if value="{{ item.hasAtt }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="첨부 있음" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
</span>
<span style="text-align: right; white-space: nowrap; font-size: 12px; font-weight: {{ item.timeFw }}; color: {{ item.timeColor }}; font-variant-numeric: tabular-nums"><span class="sr">날짜 </span>{{ item.date }}</span>
<span class="ml-size" style="text-align: right; white-space: nowrap; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums"><span class="sr">크기 </span>{{ item.size }}</span>
</li>
</sc-for>
</ul>
</div>
</div>
<div style="display: flex; justify-content: center; padding: 10px 20px 20px">
<button type="button" style="min-height: 40px; padding: 0 24px; border: 0; border-radius: 8px; background: transparent; color: #0E7C86; font: inherit; font-weight: 600; cursor: pointer">메일 더 보기</button>
</div>
</sc-if>
<sc-if value="{{ noItems }}" hint-placeholder-val="{{ false }}">
<div style="display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 96px 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>
</main>
</div>
</div>
<sc-if value="{{ confirmOpen }}" 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="alertdialog" aria-modal="true" aria-labelledby="purge-title" aria-describedby="purge-text" 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: 8px">
<h3 id="purge-title" style="margin: 0; font-size: 15px; font-weight: 700">{{ confirmTitle }}</h3>
<p id="purge-text" style="margin: 0; color: #4A5363">휴지통에서 지운 메일은 되돌릴 수 없습니다.</p>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" onClick="{{ purge }}" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; background: #B42318; color: #FFFFFF; font: inherit; font-weight: 600; cursor: pointer">영구 삭제</button>
<button type="button" class="ghost" onClick="{{ cancelPurge }}" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</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; padding: 6px 6px 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; min-height: 44px; display: flex; align-items: center; font-weight: 500">{{ toastText }}</span>
<sc-if value="{{ canUndo }}" hint-placeholder-val="{{ true }}">
<button type="button" class="on-dark" onClick="{{ undo }}" 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>
</sc-if>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":900}}'>
class Component extends DCLogic {
state = { box: 'inbox', pick: { m2: true, m4: true, m5: true }, seen: {}, star: { m3: true }, moved: {}, gone: {}, menu: false, confirm: false, toast: null, undo: null };
messages() {
return [
{ id: 'm1', box: 'inbox', who: '이도윤', subject: 'Re: 메일 서버 이전 일정 공유', date: '오전 10:42', size: '62 KB', unread: true, att: true },
{ id: 'm2', box: 'inbox', who: '인사팀', subject: '[인사팀] 11월 연차 사용 계획 제출 안내', date: '오전 9:15', size: '8 KB', unread: true, att: false },
{ id: 'm3', box: 'inbox', who: '박준호', subject: '주간 인프라 점검 결과 (10/20~10/24)', date: '10.25', size: '318 KB', unread: false, att: true },
{ id: 'm4', box: 'inbox', who: '(주)한빛솔루션', subject: '서버 증설 견적서 송부드립니다', date: '10.25', size: '190 KB', unread: true, att: true },
{ id: 'm5', box: 'inbox', who: '보안팀', subject: '[공지] 하반기 정보보안 교육 이수 요청', date: '10.24', size: '9 KB', unread: false, att: false },
{ id: 'm6', box: 'inbox', who: '최서연', subject: '10월 경비 정산 마감 안내', date: '10.23', size: '7 KB', unread: false, att: false },
{ id: 'm8', box: 'inbox', who: '정하늘', subject: '신규 입사자 메일 계정 생성 요청 (11/2 입사)', date: '10.23', size: '11 KB', unread: false, att: false },
{ id: 'm9', box: 'inbox', who: '총무팀', subject: '[총무팀] 사무실 공조 설비 점검 일정 안내', date: '10.23', size: '6 KB', unread: false, att: false },
{ id: 'm7', box: 'inbox', who: '이도윤', subject: '메일 서버 이전 일정 공유', date: '10.22', size: '15 KB', unread: false, att: false },
{ id: 'm10', box: 'inbox', who: '박준호', subject: 'Re: 백업 스토리지 증설 검토', date: '10.22', size: '96 KB', unread: false, att: true },
{ id: 'm11', box: 'inbox', who: '김태윤', subject: '회의실 예약 시스템 저장 오류 문의', date: '10.21', size: '9 KB', unread: false, att: false },
{ id: 'm13', box: 'inbox', who: '(주)한빛솔루션', subject: '유지보수 계약 갱신 안내', date: '10.20', size: '22 KB', unread: false, att: false },
{ id: 'm12', box: 'inbox', who: '인사팀', subject: '[인사팀] 10월 사내 교육 일정 공유', date: '10.20', size: '140 KB', unread: false, att: true },
{ id: 'm14', box: 'inbox', who: '보안팀', subject: '[보안] VPN 접속 프로그램 업데이트 안내', date: '10.19', size: '12 KB', unread: false, att: false },
{ id: 'm15', box: 'inbox', who: '이서준', subject: '사내 위키 메일 서버 문서 정리 요청', date: '10.16', size: '10 KB', unread: false, att: false },
{ id: 's1', box: 'sent', who: '박준호', subject: 'Re: 주간 인프라 점검 결과 (10/20~10/24)', date: '10.25', size: '6 KB', unread: false, att: false },
{ id: 's2', box: 'sent', who: '이도윤', subject: 'Re: 메일 서버 이전 일정 공유', date: '10.23', size: '7 KB', unread: false, att: false },
{ id: 'j1', box: 'junk', who: 'promo@deal.example', subject: '(광고) 오늘만 특가 — 서버 랙 90% 할인', date: '10.24', size: '31 KB', unread: true, att: false },
{ id: 't1', box: 'trash', who: '호스팅 안내', subject: '(광고) 서버 호스팅 연말 할인 안내', date: '10.21', size: '24 KB', unread: false, att: false },
{ id: 't2', box: 'trash', who: '세미나 사무국', subject: '[초대] 클라우드 보안 세미나 사전 등록', date: '10.18', size: '45 KB', unread: false, att: false },
{ id: 't3', box: 'trash', who: '박준호', subject: '백업 스크립트 테스트 (무시하세요)', date: '10.15', size: '3 KB', unread: false, att: false },
];
}
renderVals() {
const st = this.state;
const BOXES = [
{ id: 'inbox', name: '받은편지함' },
{ id: 'sent', name: '보낸편지함' },
{ id: 'drafts', name: '임시보관함' },
{ id: 'junk', name: '스팸메일함' },
{ id: 'trash', name: '휴지통' },
];
const nameOf = (id) => (BOXES.find((b) => b.id === id) || BOXES[0]).name;
const all = this.messages()
.filter((x) => !st.gone[x.id])
.map((x) => ({ ...x, box: st.moved[x.id] || x.box }));
const isUnread = (x) => (st.seen[x.id] === undefined ? x.unread : !st.seen[x.id]);
const starOf = (id) => !!st.star[id];
const boxes = BOXES.map((b) => {
const active = b.id === st.box;
const unread = all.filter((x) => x.box === b.id && isUnread(x)).length;
return {
name: b.name,
isInbox: b.id === 'inbox', isSent: b.id === 'sent', isDrafts: b.id === 'drafts', isJunk: b.id === 'junk', isTrash: b.id === 'trash',
current: active ? 'page' : 'false',
bg: active ? '#E3F2F3' : 'transparent',
fg: active ? '#0A5F67' : '#344054',
fw: active ? '600' : '500',
countColor: active ? '#0A5F67' : '#171C26',
unread: unread,
hasUnread: unread > 0,
pick: () => this.setState({ box: b.id, pick: {}, menu: false }),
};
});
const list = all.filter((x) => x.box === st.box);
const pickedIds = list.filter((x) => st.pick[x.id]).map((x) => x.id);
const n = pickedIds.length;
const inTrash = st.box === 'trash';
const items = list.map((x) => {
const picked = !!st.pick[x.id];
const unread = isUnread(x);
const starred = starOf(x.id);
return {
who: x.who,
subject: x.subject,
date: x.date,
size: x.size,
unread: unread,
hasAtt: x.att,
rowClass: picked ? 'ml-row ml-grid is-sel' : 'ml-row ml-grid',
picked: picked,
notPicked: !picked,
pickedStr: picked ? 'true' : 'false',
pickLabel: '고르기: ' + x.subject,
togglePick: () => this.setState({ pick: { ...st.pick, [x.id]: !picked }, menu: false }),
fw: unread ? '650' : '450',
whoColor: unread ? '#171C26' : '#344054',
subFw: unread ? '650' : '400',
timeFw: unread ? '600' : '400',
timeColor: unread ? '#171C26' : '#667085',
starLabel: starred ? '중요 표시 해제' : '중요 표시',
starredStr: starred ? 'true' : 'false',
starColor: starred ? '#B7791F' : '#98A2B3',
starFill: starred ? '#B7791F' : 'none',
toggleStar: () => this.setState({ star: { ...st.star, [x.id]: !starred } }),
};
});
const setMany = (map, value) => ({ ...map, ...Object.fromEntries(pickedIds.map((id) => [id, value])) });
const allFlagged = n > 0 && pickedIds.every(starOf);
const moveTo = (target) => {
const before = Object.fromEntries(pickedIds.map((id) => [id, st.moved[id] || null]));
this.setState({
moved: setMany(st.moved, target),
pick: {},
menu: false,
toast: `메일 ${n}통을 ${nameOf(target)}으로 옮겼습니다.`,
undo: before,
});
};
const targets = BOXES
.filter((b) => b.id !== st.box && b.id !== 'drafts' && b.id !== 'trash')
.map((b) => ({ name: b.name, move: () => moveTo(b.id) }));
const box = BOXES.find((b) => b.id === st.box) || BOXES[0];
const unreadInBox = list.filter(isUnread).length;
return {
boxes: boxes,
items: items,
boxName: box.name,
listLabel: box.name + ' 메일 목록',
whoHeader: st.box === 'sent' ? '받는 사람' : '보낸 사람',
countText: unreadInBox > 0 ? `전체 ${list.length} · 안 읽음 ${unreadInBox}` : `전체 ${list.length}`,
hasItems: items.length > 0,
noItems: items.length === 0,
emptyText: box.name + '이 비어 있습니다',
hasPick: n > 0,
noPick: n === 0,
pickText: `${n}통 선택됨`,
allNone: n === 0,
allSome: n > 0 && n < list.length,
allFull: n > 0 && n === list.length,
allChecked: n === 0 ? 'false' : n === list.length ? 'true' : 'mixed',
toggleAll: () => this.setState({ pick: n === list.length ? {} : Object.fromEntries(list.map((x) => [x.id, true])), menu: false }),
clearPick: () => this.setState({ pick: {}, menu: false }),
markRead: () => this.setState({ seen: setMany(st.seen, true) }),
markUnread: () => this.setState({ seen: setMany(st.seen, false) }),
flagLabel: allFlagged ? '중요 해제' : '중요 표시',
flagAll: () => this.setState({ star: setMany(st.star, !allFlagged) }),
menuOpen: st.menu,
menuOpenStr: st.menu ? 'true' : 'false',
toggleMenu: () => this.setState({ menu: !st.menu }),
targets: targets,
deleteLabel: inTrash ? '영구 삭제' : '삭제',
deletePicked: () => {
if (inTrash) this.setState({ confirm: true, menu: false });
else moveTo('trash');
},
confirmOpen: st.confirm,
confirmTitle: `메일 ${n}통을 영구 삭제할까요?`,
purge: () => this.setState({ gone: setMany(st.gone, true), pick: {}, confirm: false, toast: `메일 ${n}통을 영구 삭제했습니다.`, undo: null }),
cancelPurge: () => this.setState({ confirm: false }),
hasToast: !!st.toast,
toastText: st.toast || '',
canUndo: !!st.undo,
undo: () => {
const moved = { ...st.moved };
Object.entries(st.undo || {}).forEach(([id, prev]) => {
if (prev === null) delete moved[id];
else moved[id] = prev;
});
this.setState({ moved: moved, toast: null, undo: null });
},
};
}
}
</script>
</body>
</html>
+252
View File
@@ -0,0 +1,252 @@
<!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}
.st-grid{display:grid;grid-template-columns:32px 32px 168px 96px minmax(0,1fr) 84px 64px;column-gap:12px;align-items:center}
.sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
</style>
</helmet>
<div style="width: 1440px; min-height: 1560px; 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: 860px">
<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">3단계에서 목록에 더해지는 것들입니다. 분할 보기의 검색은 「메일 검색 · 분할 보기」, 목록 보기의 일괄 처리는 「여러 통 선택 · 목록 보기」 보드에서 눌러 볼 수 있습니다. 오류 문구는 에러 코드 사전에서 가져옵니다.</p>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 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>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">화면이 보이는 동안 1분마다, 탭으로 돌아왔을 때 바로 한 번 바뀐 것이 있는지 확인합니다. 새 메일은 목록 맨 위에 생깁니다. 목록을 내려 본 채라면 보던 위치는 그대로 두고 머리 아래에 알림 줄을 띄웁니다 — 누르면 맨 위로 갑니다.</p>
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<div style="display: flex; align-items: center; gap: 8px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 16px; border-bottom: 1px solid #E6E8EC">
<h3 style="margin: 0; font-size: 15px; font-weight: 700">받은편지함</h3>
<span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">전체 148 · 안 읽음 5</span>
</div>
<button type="button" style="display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 40px; border: 0; border-bottom: 1px solid #E6E8EC; background: #F3F8F8; color: #0A5F67; font: inherit; font-weight: 600; cursor: pointer">
<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"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></svg>
<span>새 메일 2통 · 맨 위로</span>
</button>
<div 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="color: #344054">총무팀</span><span style="font-size: 12px; color: #667085">10월 23일</span></span>
<span>[총무팀] 사무실 공조 설비 점검 일정 안내</span>
</div>
<div style="display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 50px; box-sizing: border-box; padding: 6px 16px; line-height: 1.4">
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="color: #344054">이도윤</span><span style="font-size: 12px; color: #667085">10월 22일</span></span>
<span>메일 서버 이전 일정 공유</span>
</div>
</div>
<p style="margin: 0; font-size: 12px; color: #667085">맨 위를 보고 있으면 알림 줄 없이 바로 나타납니다(굵은 글자 = 안 읽음). 검색 결과는 저절로 바꾸지 않습니다.</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>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">받은편지함에 안 읽은 메일이 있으면 브라우저 탭 제목 앞에 그 수를 붙입니다. 메일함 패널의 숫자도 같은 확인에서 바뀝니다. 소리 · 데스크톱 알림은 하지 않습니다(브라우저 권한을 물어야 해서 다음 단계).</p>
<dl style="margin: 0; display: flex; flex-direction: column; gap: 8px">
<div style="display: flex; align-items: center; gap: 12px; min-height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #E6E8EC; border-radius: 8px">
<dt style="flex: 0 0 72px; font-size: 12px; color: #667085">안 읽음 3통</dt>
<dd style="margin: 0; font-weight: 500">(3) 메일 · ARA SYSTEM</dd>
</div>
<div style="display: flex; align-items: center; gap: 12px; min-height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #E6E8EC; border-radius: 8px">
<dt style="flex: 0 0 72px; font-size: 12px; color: #667085">모두 읽음</dt>
<dd style="margin: 0; font-weight: 500">메일 · ARA SYSTEM</dd>
</div>
<div style="display: flex; align-items: center; gap: 12px; min-height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #E6E8EC; border-radius: 8px">
<dt style="flex: 0 0 72px; font-size: 12px; color: #667085">쓰기 화면</dt>
<dd style="margin: 0; font-weight: 500">(3) 메일 쓰기 · ARA SYSTEM</dd>
</div>
</dl>
<p style="margin: 0; font-size: 12px; color: #667085">확인이 실패해도 알리지 않고 다음 주기에 다시 묻습니다. 세션이 끝났으면 지금처럼 다시 로그인 창이 뜹니다.</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">목록 칸 384px — 도구는 아이콘만(이름은 툴팁)</p>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">행에 마우스를 올리거나 초점이 가면 왼쪽에 고르기 칸이 나타나고, 하나라도 고르면 모든 행에 보입니다. 행을 누르면 지금처럼 메일을 엽니다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고르고, Esc 는 선택을 풉니다.</p>
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<div role="toolbar" aria-label="고른 메일" style="display: flex; align-items: center; gap: 0; min-height: 52px; box-sizing: border-box; padding: 4px 4px 4px 12px; border-bottom: 1px solid #E6E8EC; background: #F3F8F8">
<span style="margin-right: 4px; font-size: 14px; font-weight: 700; white-space: nowrap; font-variant-numeric: tabular-nums">2통 선택됨</span>
<button type="button" class="ghost" aria-label="읽음 표시" title="읽음 표시" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; 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.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z"></path><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"></path></svg></button>
<button type="button" class="ghost" aria-label="안 읽음 표시" title="안 읽음 표시" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; 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="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></button>
<button type="button" class="ghost" aria-label="중요 표시" title="중요 표시" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; 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="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></button>
<button type="button" class="ghost" aria-label="이동" title="이동" aria-haspopup="menu" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; 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="M2 9V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-1.5"></path><path d="M2 13h10"></path><path d="m9 16 3-3-3-3"></path></svg></button>
<button type="button" aria-label="삭제" title="삭제" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #B42318; 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></button>
<button type="button" class="ghost" aria-label="선택 해제" title="선택 해제 (Esc)" style="margin-left: auto; width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
<ul style="list-style: none; margin: 0; padding: 0">
<li style="display: flex; align-items: stretch; border-bottom: 1px solid #EEF0F3; background: #E8F4F5">
<button type="button" role="checkbox" aria-checked="true" aria-label="고르기: [인사팀] 11월 연차 사용 계획 제출 안내" style="flex: 0 0 auto; width: 36px; display: flex; align-items: center; justify-content: flex-end; padding: 0; border: 0; background: transparent; cursor: pointer"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="0.25" y="0.25" width="15.5" height="15.5" rx="4" fill="#0E7C86"></rect><polyline points="4.2 8.3 6.9 10.8 11.8 5.4" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></polyline></svg></button>
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 50px; padding: 6px 12px 6px 10px; line-height: 1.4">
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="font-weight: 650">인사팀</span><span style="font-size: 12px; font-weight: 600">오전 9:15</span></span>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600">[인사팀] 11월 연차 사용 계획 제출 안내</span>
</span>
</li>
<li style="display: flex; align-items: stretch; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
<button type="button" role="checkbox" aria-checked="false" aria-label="고르기: 주간 인프라 점검 결과 (10/20~10/24)" style="flex: 0 0 auto; width: 36px; display: flex; align-items: center; justify-content: flex-end; padding: 0; border: 0; background: transparent; cursor: pointer"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="1" y="1" width="14" height="14" rx="3.5" fill="#FFFFFF" stroke="#8A94A3" stroke-width="1.5"></rect></svg></button>
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 50px; padding: 6px 12px 6px 10px; line-height: 1.4">
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="color: #344054">박준호</span><span style="font-size: 12px; color: #667085">어제</span></span>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap">주간 인프라 점검 결과 (10/20~10/24)</span>
</span>
</li>
<li style="display: flex; align-items: stretch; background: #E8F4F5">
<button type="button" role="checkbox" aria-checked="true" aria-label="고르기: 서버 증설 견적서 송부드립니다" style="flex: 0 0 auto; width: 36px; display: flex; align-items: center; justify-content: flex-end; padding: 0; border: 0; background: transparent; cursor: pointer"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="0.25" y="0.25" width="15.5" height="15.5" rx="4" fill="#0E7C86"></rect><polyline points="4.2 8.3 6.9 10.8 11.8 5.4" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></polyline></svg></button>
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 50px; padding: 6px 12px 6px 10px; line-height: 1.4">
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="font-weight: 650">(주)한빛솔루션</span><span style="font-size: 12px; font-weight: 600">어제</span></span>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600">서버 증설 견적서 송부드립니다</span>
</span>
</li>
</ul>
</div>
<p style="margin: 0; font-size: 12px; color: #667085">고르기 칸은 행 왼쪽 36px(16px 상자) — 고르지 않았고 마우스도 없을 때는 칸 없이 지금 행 그대로입니다. 분할 보기에서 여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보입니다.</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">칸 = 고르기 32 · 별 32 · 보낸 사람 168 · 메일함 96 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — 받은편지함 안에서 찾으면 메일함 칸이 없다(지금 표와 같다)</p>
</div>
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<div style="display: flex; align-items: center; gap: 6px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 8px; border-bottom: 1px solid #E6E8EC">
<a href="MainList.dc.html" aria-label="검색 닫기" title="검색 닫기 — 받은편지함으로" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; color: #4A5363; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
<h3 style="margin: 0; font-size: 15px; font-weight: 700">검색 결과</h3>
<span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">‘회의’ · 6통</span>
<span style="width: 16px"></span>
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">찾을 곳</span>
<span style="display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: #F2F4F7">
<span style="display: inline-flex; align-items: center; min-height: 36px; padding: 0 12px; border-radius: 6px; color: #4A5363; font-weight: 600">받은편지함</span>
<span style="display: inline-flex; align-items: center; min-height: 36px; padding: 0 12px; border-radius: 6px; background: #FFFFFF; color: #171C26; font-weight: 600; box-shadow: 0 1px 2px rgba(16,24,28,.14)">전체 메일함</span>
</span>
<span style="font-size: 12px; color: #667085">휴지통 · 스팸메일함은 빼고 찾습니다</span>
</div>
<div class="st-grid" style="height: 32px; box-sizing: border-box; padding: 0 8px 0 20px; background: #FBFBFC; border-bottom: 1px solid #E6E8EC; font-size: 12px; font-weight: 600; color: #667085">
<span style="display: flex; align-items: center"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="1" y="1" width="14" height="14" rx="3.5" fill="#FFFFFF" stroke="#8A94A3" stroke-width="1.5"></rect></svg></span>
<span style="display: flex; align-items: center"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></span>
<span>보낸 사람</span>
<span>메일함</span>
<span>제목</span>
<span style="text-align: right">날짜</span>
<span style="text-align: right">크기</span>
</div>
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="6">
<div class="st-grid" style="height: 38px; box-sizing: border-box; padding: 0 8px 0 20px; border-bottom: 1px solid #EEF0F3">
<span style="display: flex; align-items: center"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="1" y="1" width="14" height="14" rx="3.5" fill="#FFFFFF" stroke="#8A94A3" stroke-width="1.5"></rect></svg></span>
<span style="display: flex; align-items: center"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></span>
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ r.fw }}; color: {{ r.whoColor }}">{{ r.who }}</span>
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #667085">{{ r.box }}</span>
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ r.subFw }}">{{ r.subject }}</span>
<span style="text-align: right; white-space: nowrap; font-size: 12px; color: {{ r.timeColor }}; font-weight: {{ r.timeFw }}; font-variant-numeric: tabular-nums">{{ r.date }}</span>
<span style="text-align: right; white-space: nowrap; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ r.size }}</span>
</div>
</sc-for>
</div>
<p style="margin: 0; font-size: 12px; color: #667085">보낸편지함 · 임시보관함의 메일은 보낸 사람 칸에 받는 사람을 「받는 사람: 이름」으로 보입니다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 씁니다. 검색 결과에서도 여러 통을 골라 처리할 수 있습니다.</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>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">Enter 로 찾습니다(치는 동안에는 찾지 않는다). 보낸 사람 · 받는 사람 · 제목 · 본문에서 찾고, 여러 낱말이면 모두 들어간 메일만 찾습니다. ✕ 나 Esc 는 검색을 닫고 보던 메일함으로 돌아갑니다. 뒤로 가기도 같습니다.</p>
<ul style="margin: 0; padding: 0 0 0 18px; display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: #344054">
<li>‘회의’로 찾으면 ‘회의를’ · ‘회의는’ · ‘회의에서’ · ‘회의입니다’처럼 조사가 붙은 말도 함께 찾습니다.</li>
<li>붙여 쓴 말의 일부(‘회의실’의 ‘회의’, ‘서버이전’의 ‘서버’)와 끝이 바뀐 말(‘보냈습니다’의 ‘보내’)은 찾지 못합니다.</li>
<li>제목 맨 앞의 말머리([공지] · [긴급] · Re: · Fwd:)는 메일 엔진이 색인하지 않아 찾지 못합니다. 제목 가운데의 [결재] 는 찾습니다.</li>
<li>따옴표로 감싸면 그 구절 그대로 찾습니다 — "회의를 잡겠습니다".</li>
</ul>
</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">오른쪽 아래 · 되돌리기가 있는 알림은 10초</p>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">읽음 · 안 읽음 · 중요 표시는 목록이 바로 바뀌므로 알리지 않습니다. 옮기거나 지운 것만 알리고, 「되돌리기」는 원래 메일함으로 다시 옮깁니다.</p>
<div role="status" style="display: flex; align-items: center; gap: 10px; padding: 6px 6px 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">메일 3통을 휴지통으로 옮겼습니다.</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; 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; padding: 6px 0; font-weight: 500">메일 2통을 보낸편지함으로 옮겼습니다. 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; 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">메일 3통을 영구 삭제했습니다.</span>
</div>
</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>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">휴지통에서 고르면 도구 줄의 「삭제」가 「영구 삭제」가 되고, 누르면 먼저 묻습니다. 대화상자가 열리면 「취소」에 초점이 갑니다. 「이동」으로 다른 메일함에 되돌릴 수 있습니다.</p>
<div role="alertdialog" aria-labelledby="st-purge-title" aria-describedby="st-purge-text" style="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: 8px">
<h3 id="st-purge-title" style="margin: 0; font-size: 15px; font-weight: 700">메일 3통을 영구 삭제할까요?</h3>
<p id="st-purge-text" style="margin: 0; color: #4A5363">휴지통에서 지운 메일은 되돌릴 수 없습니다.</p>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; background: #B42318; color: #FFFFFF; font: inherit; font-weight: 600; cursor: pointer">영구 삭제</button>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
<p style="margin: 0; font-size: 12px; color: #667085">그사이 다른 창에서 휴지통 밖으로 되돌린 메일은 지우지 않습니다(서버가 확인한 상태 그대로일 때만 지운다).</p>
</section>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1560}}'>
class Component extends DCLogic {
renderVals() {
const R = [
{ who: '이도윤', box: '받은편지함', subject: 'Re: 주간 회의 안건 공유', date: '오전 10:42', size: '58 KB', unread: true },
{ who: '박준호', box: '받은편지함', subject: '11월 인프라 점검 일정 조율', date: '오전 9:05', size: '7 KB', unread: true },
{ who: '받는 사람: 정하늘', box: '보낸편지함', subject: 'Re: 신규 입사자 교육 회의록', date: '10.26', size: '5 KB', unread: false },
{ who: '최서연', box: '받은편지함', subject: '10월 경비 정산 일정 변경', date: '10.23', size: '7 KB', unread: false },
{ who: '받는 사람: 운영팀', box: '임시보관함', subject: '11월 운영 회의 안건 (작성 중)', date: '10.22', size: '4 KB', unread: false },
{ who: '인사팀', box: '받은편지함', subject: '[인사팀] 전사 회의 참석 안내', date: '10.19', size: '226 KB', unread: false },
];
return {
rows: R.map((r) => ({
who: r.who,
box: r.box,
subject: r.subject,
date: r.date,
size: r.size,
fw: r.unread ? '650' : '450',
whoColor: r.unread ? '#171C26' : '#344054',
subFw: r.unread ? '650' : '400',
timeFw: r.unread ? '600' : '400',
timeColor: r.unread ? '#171C26' : '#667085',
})),
};
}
}
</script>
</body>
</html>
+66 -2
View File
@@ -1,12 +1,13 @@
# 웹메일 디자인 원본 (1·2단계)
# 웹메일 디자인 원본 (1~3단계)
확정된 디자인 캔버스의 원본이다. 화면 구현의 기준으로 쓴다.
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 19)
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 21)
- 화면: `Login`(로그인) · `Main`(분할 보기: 메일함·목록·본문) · `MainList`(목록 보기: 게시판형 표) · `MainListRead`(목록 보기에서 연 메일)
· `Compose`(메일 쓰기 — 서식 편집기) · `ComposeEditor`(메일 쓰기 · 편집기 상태: 포커스·툴바·메뉴·팝오버·인용·이미지 첨부)
· `States`(오류·빈 상태·피드백 모음)
· 2단계: `Settings`(설정 · 메일 서명) · `ComposeAssist`(메일 쓰기 · 임시 저장 · 받는 사람 자동 완성 · 서명) — 아래 "2단계"
· 3단계: `Search`(메일 검색 · 분할 보기) · `BulkSelect`(여러 통 선택 · 목록 보기) · `ListStates`(새 메일 · 검색 · 여러 통 처리 상태) — 아래 "3단계"
- 파일 형식은 캔버스 전용(`<x-dc>` 마크업 + `DCLogic` 클래스)이다. 그대로 쓰지 말고 **구조·간격·색·문구의 기준**으로만 본다.
`Compose` 의 툴바·메뉴는 눌러 볼 수 있는 시안이다 — 버튼 상태만 바뀌고 본문 글자에 서식이 입혀지지는 않는다.
@@ -215,6 +216,52 @@
화살표로 고른 뒤에만 추천이 들어간다. 엉뚱한 사람이 받는 사람에 들어가지 않게 하기 위해서다.
- 쓰기 화면을 열 때 추천 목록을 한 번 받아 두고 치는 대로 바로 거른다 — 모자랄 때만 서버에 다시 묻는다.
## 3단계 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정 — `Search` · `BulkSelect` · `ListStates`)
API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러 통 한꺼번에".
### 새 메일
- 화면이 보이는 동안 1분마다(탭으로 돌아오면 바로 한 번) 바뀐 것이 있는지 묻고, 바뀌었으면 메일함 숫자와 보이는 목록을 맞춘다 —
고른 메일 · 열린 메일 · 스크롤 위치는 그대로다. 검색 결과는 저절로 바꾸지 않는다. 확인이 실패해도 알리지 않는다.
- 맨 위를 보고 있으면 새 메일이 그냥 위에 생긴다(굵은 글자 = 안 읽음). 목록을 내려 본 채면 머리 아래에 알림 줄
"새 메일 N통 · 맨 위로"(높이 40 · 바탕 #F3F8F8 · #0A5F67 굵게 · 위 화살표)를 띄운다 — 누르면 맨 위로 가고 줄은 사라진다.
- 탭 제목: 받은편지함에 안 읽은 메일이 있으면 앞에 "(N) " — "(3) 메일 · ARA SYSTEM". 소리 · 데스크톱 알림은 하지 않는다.
### 검색
- 상단 검색 칸이 메일 화면에서는 "메일 검색"이다. Enter 로 찾고, 치는 동안에는 찾지 않는다.
검색 중인 칸은 테두리가 강조색(1px 테두리 + 1px 그림자)이고 끝에 ✕(40 · "검색어 지우기" — Esc 와 같다).
- 결과는 보던 보기 방식(분할 · 목록) 그대로 목록 자리에 보인다.
- 머리(52): [←](40×44 · "검색 닫기") · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px #667085) · 보기 전환.
- 그 아래 범위 줄(48 · 바탕 #FBFBFC): "찾을 곳" + [그 메일함 | 전체 메일함](36 — 설정 화면의 전환과 같은 밀도 예외)
+ 전체일 때 "휴지통 · 스팸메일함은 빼고 찾습니다"(12px #667085).
- 전체 메일함에서 찾으면 행에 메일함을 보인다 — 분할 보기는 첫 줄 시각 앞에 "보낸편지함 ·"(12px #667085),
목록 보기 표는 보낸 사람 뒤에 메일함 칸(96). 보낸편지함 · 임시보관함의 메일은 보낸 사람 자리에 "받는 사람: 이름".
- 검색 결과에서 연 메일의 읽기 화면은 받는 사람 아래에 "메일함 받은편지함" 한 줄(12.5px #667085)을 더한다.
- 결과 없음: 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 한계 한 줄 "낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나
제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다." · 한 메일함에서 찾았으면 [전체 메일함에서 찾기].
- 찾는 동안은 지금의 목록 뼈대를 보인다.
### 여러 통 처리
- **목록 보기 표**: 맨 앞에 고르기 칸(32 — 별 칸과 같은 밀도 예외). 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크.
머리의 상자는 불러온 메일 모두 고르기 · 풀기(일부만 골랐으면 – 표시). 고른 행 바탕 #E8F4F5(마우스를 올리면 #DDEFF0).
칸 = 고르기 32 · 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12). 좁아질 때는 지금 규칙 그대로(1100 미만 크기 칸 숨김 · 600 미만 보낸 사람 112 · 날짜 72).
- **분할 보기**: 행에 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다.
행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위, Esc 는 선택 풀기 (두 보기 공통).
여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보인다.
- **도구 줄**: 하나라도 고르면 목록 머리(52)가 도구 줄로 바뀐다(바탕 #F3F8F8) — "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 ·
중요 표시(고른 것이 모두 중요면 "중요 해제") | 이동 ▾ · 삭제(휴지통에서는 "영구 삭제", #B42318) … 오른쪽 끝 "선택 해제"(✕).
단추는 높이 44 · 아이콘 + 이름이고, 폭 1100 미만이거나 분할 보기의 목록 칸에서는 아이콘만(40×44 — 이름은 툴팁 · `aria-label`).
- **이동 메뉴**(항목 높이 40 — 메뉴 항목 밀도 예외): 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함.
- **알림**(오른쪽 아래 · 되돌리기가 있으면 10초): "메일 N통을 보낸편지함으로 옮겼습니다." [되돌리기] · "메일 N통을 휴지통으로 옮겼습니다." [되돌리기] ·
"메일 N통을 영구 삭제했습니다." · 일부만 처리했으면 뒤에 "1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다."
(메일함 이름의 받침에 맞춰 '으로/로'). 읽음 · 중요 표시는 목록이 바로 바뀌므로 알리지 않는다.
- **영구 삭제 확인**: 휴지통에서만. 제목 "메일 N통을 영구 삭제할까요?" · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." ·
[영구 삭제](#B42318 채움) [취소] — 열리면 「취소」에 초점이 간다.
- 메일함을 바꾸거나 검색하면 선택을 푼다.
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -256,3 +303,20 @@
- 설정 화면의 미리 보기 전환 단추와 그림 크기 줄(작게 · 보통 · 원본 · 그림 빼기, 36px)은 목록의 보기 전환처럼 밀도 예외다 (캔버스 확정값).
- **받는 사람 자동 완성**: Tab 은 고른 추천을 넣고 그 칸에 머문다(고른 것이 없으면 원래 Tab). 화살표는 끝에서 처음으로 돌고, 마우스를 올린 줄이 골라진다.
초성 · 자모만 친 글자는 서버에 묻지 않는다(받아 둔 목록에서만 거른다).
### 3단계 구현하며 정한 보완 (2026-10-27 — 캔버스에는 아직 없다)
- **읽기 화면의 삭제**: 휴지통 밖의 메일은 휴지통으로 **옮기고**(알림 "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]),
휴지통의 메일은 단추가 「영구 삭제」가 되어 여러 통과 같은 확인 대화상자를 거친다 — 그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하지 않게
(웹 화면은 삭제 요청 `DELETE` 를 쓰지 않는다 — 계약 2절 "여러 통 한꺼번에").
- **검색**
- 검색 화면의 탭 제목은 "메일 검색"이다 — "(3) 메일 검색 · ARA SYSTEM".
- 낱말이 8개를 넘으면 찾지 않고 알린다(서버 한도와 같다). 같은 검색어로 다시 Enter 를 누르면 보이는 채로 다시 찾고 고른 것을 푼다.
- 주소로 바로 들어와 검색을 시작한 곳을 모르면, 닫을 때 그 범위의 메일함으로 간다(범위가 전체면 받은편지함).
- 검색 화면 안에서 다시 찾거나 범위를 바꾸면 방문 기록을 쌓지 않는다(메일함에서 시작한 첫 검색만 쌓인다).
- 전체 메일함 결과에서 다른 메일함으로 옮긴 메일은 결과에 남기고 메일함 표시만 바꾼다(휴지통 · 스팸메일함으로 옮기면 뺀다).
- **여러 통 처리**
- 목록 보기의 「선택 해제」는 폭 1100 미만에서도 이름을 남긴다(분할 보기의 목록 칸에서는 아이콘만).
- 요청이 통째로 실패하면 고른 것을 그대로 둔다(다시 시도할 수 있게). 열어 둔 메일이 옮겨져 목록에서 빠지면 목록 주소로 바꾼다.
- 영구 삭제 확인은 공용 대화상자 폭(360)을 쓴다.
- 「되돌리기」는 이 화면이 알던 메일함 한 곳으로 되돌리고, 로그아웃 · 다른 계정 로그인 뒤에는 움직이지 않는다 (계약 2절 "알려진 한계").
+436
View File
@@ -0,0 +1,436 @@
<!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}
.seg{background:transparent;color:#4A5363}
.seg[aria-pressed="true"]{background:#FFFFFF;color:#171C26;box-shadow:0 1px 2px rgba(16,24,28,.14)}
.ghost{background:transparent;color:#344054}
.ghost:hover{background:#F2F4F7;color:#171C26}
.sk{display:block;height:10px;border-radius:5px;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" 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 0 0 12px; border: 1px solid #0E7C86; border-radius: 8px; background: #FFFFFF; box-shadow: 0 0 0 1px #0E7C86">
<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="메일 검색" value="회의" style="flex: 1 1 auto; min-width: 0; border: 0; outline: none; background: transparent; font: inherit; color: #171C26">
<a href="Main.dc.html" aria-label="검색어 지우기" title="검색어 지우기 (Esc)" style="flex: 0 0 auto; width: 40px; height: 38px; display: inline-flex; align-items: center; justify-content: center; border-radius: 0 7px 7px 0; color: #667085; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</a>
</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>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ boxes }}" as="b" hint-placeholder-count="5">
<li>
<a href="Main.dc.html" aria-current="{{ b.current }}" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: {{ b.bg }}; color: {{ b.fg }}; font-weight: {{ b.fw }}; text-decoration: none">
<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">{{ 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>
</a>
</li>
</sc-for>
</ul>
<div style="margin-top: auto; 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>
</nav>
<main style="flex: 999 1 560px; min-width: 0; display: flex; flex-wrap: wrap; align-items: stretch">
<section aria-labelledby="result-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: 6px; min-height: 52px; box-sizing: border-box; padding: 4px 4px 4px 4px; border-bottom: 1px solid #E6E8EC">
<a href="Main.dc.html" aria-label="검색 닫기" title="검색 닫기 — 받은편지함으로" style="flex: 0 0 auto; width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; color: #4A5363; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
</a>
<h2 id="result-title" style="margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">검색 결과</h2>
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ countText }}</span>
<div style="margin-left: auto; display: inline-flex; gap: 2px; padding: 2px; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
<button type="button" aria-pressed="true" aria-label="분할 보기" style="display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 0 8px; border: 0; border-radius: 6px; background: #E3F2F3; color: #0A5F67; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" ry="2"></rect><line x1="10" y1="4" x2="10" y2="20"></line></svg>
<span>분할</span>
</button>
<a href="ListStates.dc.html" aria-label="목록 보기" style="display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 0 8px; border-radius: 6px; color: #344054; font-size: 12px; font-weight: 500; text-decoration: none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="9" y1="6" x2="20" y2="6"></line><line x1="9" y1="12" x2="20" y2="12"></line><line x1="9" y1="18" x2="20" y2="18"></line><line x1="4" y1="6" x2="4.01" y2="6"></line><line x1="4" y1="12" x2="4.01" y2="12"></line><line x1="4" y1="18" x2="4.01" y2="18"></line></svg>
<span>목록</span>
</a>
</div>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-height: 48px; box-sizing: border-box; padding: 4px 12px 4px 16px; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
<span id="scope-label" style="font-size: 12.5px; font-weight: 600; color: #4A5363">찾을 곳</span>
<div role="group" aria-labelledby="scope-label" style="display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: #F2F4F7">
<button type="button" class="seg" aria-pressed="{{ boxPressed }}" onClick="{{ scopeBox }}" style="min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">받은편지함</button>
<button type="button" class="seg" aria-pressed="{{ allPressed }}" onClick="{{ scopeAll }}" style="min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">전체 메일함</button>
</div>
<sc-if value="{{ isAll }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; color: #667085">휴지통 · 스팸메일함은 빼고 찾습니다</span></sc-if>
</div>
<sc-if value="{{ showList }}" hint-placeholder-val="{{ true }}">
<ul aria-label="검색 결과" style="list-style: none; margin: 0; padding: 0">
<sc-for list="{{ items }}" as="item" hint-placeholder-count="5">
<li style="display: flex; align-items: stretch; border-bottom: 1px solid #EEF0F3; background: {{ item.bg }}">
<button type="button" onClick="{{ item.pick }}" style="flex: 1 1 auto; min-width: 0; min-height: 50px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; gap: 1px; padding: 6px 2px 6px 16px; border: 0; background: transparent; font: inherit; line-height: 1.4; color: inherit; text-align: left; cursor: pointer">
<span style="display: flex; align-items: center; gap: 6px">
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.fw }}; color: {{ item.whoColor }}">{{ item.who }}</span>
<sc-if value="{{ item.showBox }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; color: #667085">{{ item.boxName }} ·</span></sc-if>
<span style="flex: 0 0 auto; font-size: 12px; font-weight: {{ item.timeFw }}; color: {{ item.timeColor }}; font-variant-numeric: tabular-nums">{{ item.time }}</span>
</span>
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.subFw }}; color: #171C26">{{ item.subject }}</span>
<sc-if value="{{ item.hasAtt }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="첨부 있음" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
</span>
</button>
<button type="button" onClick="{{ item.toggleStar }}" aria-label="{{ item.starLabel }}" aria-pressed="{{ item.starredStr }}" style="flex: 0 0 auto; width: 44px; display: flex; align-items: center; justify-content: center; border: 0; background: transparent; color: {{ item.starColor }}; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="{{ item.starFill }}" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
</button>
</li>
</sc-for>
</ul>
</sc-if>
<sc-if value="{{ showLoading }}" hint-placeholder-val="{{ false }}">
<ul aria-label="찾는 중" aria-busy="true" style="list-style: none; margin: 0; padding: 0">
<sc-for list="{{ skeleton }}" as="s" hint-placeholder-count="6">
<li style="display: flex; flex-direction: column; justify-content: center; gap: 8px; min-height: 50px; box-sizing: border-box; padding: 8px 16px; border-bottom: 1px solid #EEF0F3">
<span style="display: flex; justify-content: space-between; gap: 24px"><span class="sk" style="width: {{ s.a }}"></span><span class="sk" style="width: 40px"></span></span>
<span class="sk" style="width: {{ s.b }}"></span>
</li>
</sc-for>
</ul>
</sc-if>
<sc-if value="{{ showEmpty }}" hint-placeholder-val="{{ false }}">
<div style="display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 72px 28px; 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"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<p style="margin: 6px 0 0; font-size: 14px; font-weight: 600">‘회의’에 맞는 메일이 없습니다</p>
<p style="margin: 0; max-width: 300px; font-size: 12.5px; color: #667085">낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.</p>
<sc-if value="{{ offerAll }}" hint-placeholder-val="{{ true }}">
<button type="button" onClick="{{ scopeAll }}" style="margin-top: 8px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; background: transparent; color: #0E7C86; font: inherit; font-weight: 600; cursor: pointer">전체 메일함에서 찾기</button>
</sc-if>
</div>
</sc-if>
</section>
<div style="flex: 999 1 480px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
<sc-if value="{{ hasSel }}" hint-placeholder-val="{{ true }}">
<article aria-labelledby="mail-subject" style="display: flex; flex-direction: column">
<div style="border-bottom: 1px solid #E6E8EC"><div style="display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; max-width: 800px; box-sizing: border-box; padding: 4px 32px 4px 22px">
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; 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"><polyline points="9 14 4 9 9 4"></polyline><path d="M20 20v-7a4 4 0 0 0-4-4H4"></path></svg>
<span>답장</span>
</a>
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; 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"><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>
</a>
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; 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"><polyline points="15 14 20 9 15 4"></polyline><path d="M4 20v-7a4 4 0 0 1 4-4h12"></path></svg>
<span>전달</span>
</a>
<span aria-hidden="true" style="width: 1px; height: 20px; margin: 0 6px; background: #E6E8EC"></span>
<button type="button" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: #B42318; 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></div>
<div style="display: flex; flex-direction: column; gap: 18px; max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px">
<div style="display: flex; align-items: flex-start; gap: 8px">
<h1 id="mail-subject" style="flex: 1 1 auto; margin: 4px 0 0; font-size: 20px; line-height: 1.4; font-weight: 650; letter-spacing: -0.015em">{{ m.subject }}</h1>
<button type="button" onClick="{{ toggleStarCur }}" aria-label="{{ m.starLabel }}" aria-pressed="{{ m.starredStr }}" style="flex: 0 0 auto; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: {{ m.starColor }}; cursor: pointer">
<svg width="18" height="18" viewBox="0 0 24 24" fill="{{ m.starFill }}" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
</button>
</div>
<div style="display: flex; align-items: flex-start; gap: 12px">
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 600">{{ m.initial }}</span>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
<div style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px">
<span style="font-size: 13.5px; font-weight: 600">{{ m.fromName }}</span>
<span style="font-size: 12.5px; color: #667085">{{ m.fromEmail }}</span>
<span style="margin-left: auto; font-size: 12.5px; color: #667085; white-space: nowrap; font-variant-numeric: tabular-nums">{{ m.date }}</span>
</div>
<span style="font-size: 12.5px; color: #4A5363">받는 사람 {{ m.to }}</span>
<span style="font-size: 12.5px; color: #667085">{{ m.boxLine }}</span>
</div>
</div>
<div style="white-space: pre-line; font-size: 14px; line-height: 1.75; color: #1F2733; max-width: 680px">{{ m.body }}</div>
<sc-if value="{{ m.hasAtt }}" hint-placeholder-val="{{ true }}">
<section aria-labelledby="att-title" style="display: flex; flex-direction: column; gap: 8px; padding-top: 16px; border-top: 1px solid #EEF0F3">
<h2 id="att-title" style="margin: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">첨부 파일 {{ m.attCount }}개</h2>
<ul style="list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 8px">
<sc-for list="{{ m.atts }}" as="att" hint-placeholder-count="1">
<li style="display: flex; align-items: center; gap: 10px; padding: 4px 2px 4px 8px; border: 1px solid #E6E8EC; border-radius: 8px">
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; background: #F2F4F7; color: #4A5363">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
</span>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35">
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500">{{ att.name }}</span>
<span style="font-size: 12px; color: #667085">{{ att.size }}</span>
</div>
<button type="button" aria-label="{{ att.label }}" style="flex: 0 0 auto; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="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>
</button>
</li>
</sc-for>
</ul>
</section>
</sc-if>
</div>
</article>
</sc-if>
<sc-if value="{{ noSel }}" hint-placeholder-val="{{ false }}">
<div style="flex: 1 1 auto; 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>
</sc-if>
</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 = { scope: 'box', sel: 'r1', read: {}, star: {} };
results() {
return [
{
id: 'r1', box: 'inbox', who: '이도윤', fromName: '이도윤', fromEmail: 'doyun.lee@arasystem.it.kr',
to: '김아라 <ara.kim@arasystem.it.kr>', subject: 'Re: 주간 회의 안건 공유', time: '오전 10:42',
date: '2026. 10. 27. (화) 오전 10:42', unread: true,
body: '김아라님, 안녕하세요.\n\n목요일 주간 회의 안건을 정리해 보냅니다.\n메일 서버 전환 일정과 백업 스토리지 증설 건을 먼저 다루면 좋겠습니다.\n\n이도윤 드림',
atts: [{ name: '주간회의_안건_1029.docx', size: '36 KB' }],
},
{
id: 'r2', box: 'inbox', who: '박준호', fromName: '박준호', fromEmail: 'junho.park@arasystem.it.kr',
to: '인프라팀 <infra@arasystem.it.kr>', subject: '11월 인프라 점검 일정 조율', time: '오전 9:05',
date: '2026. 10. 27. (화) 오전 9:05', unread: true,
body: '인프라팀 여러분,\n\n11월 점검 일정을 정하려고 합니다.\n다음 주 화요일 오후에 짧게 회의를 잡겠습니다. 안 되시는 분은 알려 주세요.\n\n박준호',
atts: [],
},
{
id: 'r3', box: 'sent', who: '받는 사람: 정하늘', fromName: '김아라', fromEmail: 'ara.kim@arasystem.it.kr',
to: '정하늘 <haneul.jung@arasystem.it.kr>', subject: 'Re: 신규 입사자 교육 회의록', time: '어제',
date: '2026. 10. 26. (월) 오후 5:48', unread: false,
body: '하늘님,\n\n회의록 잘 받았습니다. 메일 계정은 입사 전날까지 만들어 두겠습니다.\n\n김아라',
atts: [],
},
{
id: 'r4', box: 'inbox', who: '최서연', fromName: '최서연', fromEmail: 'seoyeon.choi@arasystem.it.kr',
to: '전체 구성원 <all@arasystem.it.kr>', subject: '10월 경비 정산 일정 변경', time: '10월 23일',
date: '2026. 10. 23. (금) 오후 4:20', unread: false,
body: '안녕하세요, 재무팀 최서연입니다.\n\n오늘 회의에서 정한 대로 10월분 경비 마감을 11월 4일(수)로 미룹니다.\n증빙은 지금처럼 사내 시스템에 올려 주세요.',
atts: [],
},
{
id: 'r5', box: 'drafts', who: '받는 사람: 운영팀', fromName: '김아라', fromEmail: 'ara.kim@arasystem.it.kr',
to: '운영팀 <ops@arasystem.it.kr>', subject: '11월 운영 회의 안건 (작성 중)', time: '10월 22일',
date: '2026. 10. 22. (목) 오후 2:10', unread: false,
body: '운영팀 여러분,\n\n11월 운영 회의는 둘째 주 목요일에 하려고 합니다.\n안건은 아래와 같습니다.',
atts: [],
},
{
id: 'r6', box: 'inbox', who: '인사팀', fromName: '인사팀', fromEmail: 'hr@arasystem.it.kr',
to: '전체 구성원 <all@arasystem.it.kr>', subject: '[인사팀] 전사 회의 참석 안내', time: '10월 19일',
date: '2026. 10. 19. (월) 오전 10:00', unread: false,
body: '구성원 여러분, 인사팀입니다.\n\n10월 30일(금) 오후 4시 전사 회의에 참석해 주세요.\n장소와 순서는 첨부 안내문에 있습니다.',
atts: [{ name: '전사회의_안내.pdf', size: '220 KB' }],
},
];
}
renderVals() {
const st = this.state;
const variant = this.props.variant ?? '결과';
const BOXNAME = { inbox: '받은편지함', sent: '보낸편지함', drafts: '임시보관함' };
const isAll = st.scope === 'all';
const inScope = this.results().filter((x) => isAll || x.box === 'inbox');
const list = variant === '결과' ? inScope : [];
const isUnread = (x) => x.unread && !st.read[x.id];
const starOf = (id) => !!st.star[id];
const PANEL = [
{ id: 'inbox', name: '받은편지함', unread: 3 },
{ id: 'sent', name: '보낸편지함', unread: 0 },
{ id: 'drafts', name: '임시보관함', unread: 0 },
{ id: 'junk', name: '스팸메일함', unread: 0 },
{ id: 'trash', name: '휴지통', unread: 0 },
];
const boxes = PANEL.map((b) => {
const active = !isAll && b.id === 'inbox';
return {
name: b.name,
isInbox: b.id === 'inbox', isSent: b.id === 'sent', isDrafts: b.id === 'drafts', isJunk: b.id === 'junk', isTrash: b.id === 'trash',
current: active ? 'page' : 'false',
bg: active ? '#E3F2F3' : 'transparent',
fg: active ? '#0A5F67' : '#344054',
fw: active ? '600' : '500',
countColor: active ? '#0A5F67' : '#171C26',
unread: b.unread,
hasUnread: b.unread > 0,
};
});
const items = list.map((x) => {
const selected = x.id === st.sel;
const unread = isUnread(x);
const starred = starOf(x.id);
return {
who: x.who,
subject: x.subject,
time: x.time,
hasAtt: x.atts.length > 0,
showBox: isAll,
boxName: BOXNAME[x.box],
bg: selected ? '#E3F2F3' : '#FFFFFF',
fw: unread ? '650' : '450',
whoColor: unread ? '#171C26' : '#344054',
subFw: unread ? '600' : '400',
timeFw: unread ? '600' : '400',
timeColor: unread ? '#171C26' : '#667085',
starLabel: starred ? '중요 표시 해제' : '중요 표시',
starredStr: starred ? 'true' : 'false',
starColor: starred ? '#B7791F' : '#98A2B3',
starFill: starred ? '#B7791F' : 'none',
pick: () => this.setState({ sel: x.id, read: { ...st.read, [x.id]: true } }),
toggleStar: () => this.setState({ star: { ...st.star, [x.id]: !starred } }),
};
});
const cur = list.find((x) => x.id === st.sel) || null;
const curStarred = cur ? starOf(cur.id) : false;
const base = cur || { subject: '', fromName: '', fromEmail: '', to: '', date: '', body: '', atts: [], box: 'inbox' };
const m = {
subject: base.subject,
fromName: base.fromName,
fromEmail: base.fromEmail,
to: base.to,
date: base.date,
body: base.body,
boxLine: cur ? '메일함 ' + BOXNAME[base.box] : '',
initial: base.fromName ? base.fromName.slice(0, 1) : '',
atts: base.atts.map((a) => ({ name: a.name, size: a.size, label: a.name + ' 다운로드' })),
attCount: base.atts.length,
hasAtt: base.atts.length > 0,
starLabel: curStarred ? '중요 표시 해제' : '중요 표시',
starredStr: curStarred ? 'true' : 'false',
starColor: curStarred ? '#B7791F' : '#98A2B3',
starFill: curStarred ? '#B7791F' : 'none',
};
return {
boxes: boxes,
items: items,
countText: variant === '찾는 중' ? '‘회의’ · 찾는 중' : `‘회의’ · ${list.length}통`,
isAll: isAll,
boxPressed: isAll ? 'false' : 'true',
allPressed: isAll ? 'true' : 'false',
scopeBox: () => this.setState({ scope: 'box', sel: 'r1' }),
scopeAll: () => this.setState({ scope: 'all', sel: 'r1' }),
showList: variant === '결과',
showLoading: variant === '찾는 중',
showEmpty: variant === '결과 없음',
offerAll: !isAll,
skeleton: [{ a: '38%', b: '72%' }, { a: '30%', b: '64%' }, { a: '44%', b: '80%' }, { a: '26%', b: '58%' }, { a: '36%', b: '70%' }, { a: '32%', b: '62%' }],
hasSel: !!cur,
noSel: !cur,
m: m,
toggleStarCur: () => {
if (!cur) return;
this.setState({ star: { ...st.star, [cur.id]: !curStarred } });
},
};
}
}
</script>
</body>
</html>
+7 -3
View File
@@ -13,12 +13,16 @@
"MainListRead.dc.html": { "x": 3040, "y": 1020, "w": 1440, "h": 900, "title": "목록 보기 · 메일 읽기", "expand": "fill", "is_interactive": true },
"ComposeEditor.dc.html": { "x": 4560, "y": 0, "w": 1440, "h": 3576, "title": "메일 쓰기 · 편집기 상태" },
"Settings.dc.html": { "x": 0, "y": 2480, "w": 1440, "h": 900, "title": "설정 · 메일 서명", "expand": "fill", "is_interactive": true },
"ComposeAssist.dc.html": { "x": 1520, "y": 2480, "w": 1440, "h": 1400, "title": "메일 쓰기 · 임시 저장 · 자동 완성 · 서명" }
"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": "새 메일 · 검색 · 여러 통 처리 상태" }
},
"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"],
"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"],
"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 }
"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 }
},
"designSystems": []
}
+111 -5
View File
@@ -176,10 +176,13 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| --- | --- | --- | --- |
| `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` |
| `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` |
| `GET /api/messages` | `mailboxId`(필수) · `cursor` · `limit`(기본 20, 최대 100) · `q`(선택) | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `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` |
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다 | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId? }` — 여러 통 (아래 "여러 통 한꺼번에") | 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` |
@@ -197,6 +200,7 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| 상황 | 응답 |
| --- | --- |
| 없는 `mailboxId`, 엔진이 해석하지 못하는 `mailboxId` | 404 `RES_001` |
| 엔진이 해석하지 못하는 id — 여러 통 처리의 `ids`(`PATCH /api/messages` · `POST /api/message-deletions`), 메일함 없이 하는 검색 | 400 `VAL_001` (본문에서 온 값이라서 — 메일함 하나의 목록은 위처럼 404 `RES_001`) |
| 형식이 틀린 경로 id | 404 `RES_001` |
| 받는 사람 합계 100명 초과 (배열 하나가 100을 넘으면 `VAL_001`) | 400 `MAIL_002` |
| 엔진에서 찾을 수 없는 첨부 id — 올려 두고 오래 지나 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등 | 400 `MAIL_004` — 클라이언트는 해당 첨부를 실패로 표시한다. 올린 파일은 다시 올리게, 전달하는 원본 첨부는 빼게 한다 (원본은 다시 올릴 수 없다) |
@@ -348,7 +352,8 @@ interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string
- 옛것은 **확인한 뒤로 메일함이 바뀌지 않았을 때만** 지운다(JMAP `ifInState`, 엇갈리면 다시 확인 — 모두 세 번까지). 지우기는 되돌릴 수 없어서다.
그사이 다른 곳에서 옮겼으면 지우지 않고, 계속 엇갈리면 옛것이 한 통 더 남는다 (저장은 성공).
- **목록·버리기·되돌리기**는 기존 API 다 — 목록 `GET /api/messages?mailboxId=<임시보관함>`(보내기 중에 생긴 내부 초안은 나오지 않는다),
버리기 `DELETE /api/messages/{id}`(휴지통으로), 되돌리기 `PATCH /api/messages/{id}` `{ mailboxId: <임시보관함> }`.
버리기는 휴지통으로 옮기기 `PATCH /api/messages/{id}` `{ mailboxId: <휴지통> }`(웹은 `DELETE` 를 쓰지 않는다 — 이미 휴지통인 메일은 `DELETE` 가 영구 삭제한다),
되돌리기 `PATCH /api/messages/{id}` `{ mailboxId: <임시보관함> }`.
- **보내기**: 이어 쓴 초안은 발송 본문에 `draftId` 를 넣어 보낸다. 서버는 지금의 발송 경로(정화·지문·중복 방지 그대로)로 보내고, 성공한 뒤
(같은 키 재시도로 처음 결과를 돌려줄 때 포함) 그 초안을 지운다 — 초안 자체를 그대로 제출하지 않는다. 지우기는 위 저장과 같은 조건(임시보관함의 초안 ·
확인한 뒤 바뀌지 않음)일 때만 하고, 못 지우면 로그만 남긴다(발송은 성공). `draftId` 는 지문에 넣지 않는다.
@@ -430,7 +435,108 @@ interface RecipientSuggestion {
같은 사람의 동시 요청은 한 번만 모은다. 로그에는 개수만 남긴다.
- **클라이언트**: 쓰기 화면을 열 때 `q` 없이 `limit=100` 으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자랄 때만 `q` 로 다시 묻는다(250ms 기다린 뒤).
### 새 메일 확인 (`/api/mail-state`) — 3단계
```ts
// GET /api/mail-state — 새 메일 · 다른 창(메일 프로그램)의 변화를 알아채는 데 쓴다
interface MailState {
state: string // 불투명 — 메일함이나 메일이 하나라도 바뀌면 달라진다. 해석하지 말고 지난 값과 같은지만 본다
}
```
- 서버는 엔진의 메일함 · 메일 상태 문자열을 이어 붙여 돌려준다 (JMAP `Mailbox/get` · `Email/get` 에 `ids: []` — 메일을 읽지 않는 가벼운 조회).
새 메일이 오거나 다른 창 · 메일 프로그램이 읽음 · 이동 · 삭제를 하면 값이 바뀐다 (2026-10-27 개발 엔진에서 확인). 응답은 `Cache-Control: private, no-store`.
- **클라이언트**: 로그인한 동안, 화면이 보이는 동안(`visibilityState === 'visible'`)만 60초마다 묻는다. 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번. 한 번에 하나씩.
- 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 메일함 개수 · 사용량을 다시 받고, 보이는 메일함 목록을 **지금까지 불러온 만큼(최대 100통)** 다시 받아 맞춘다 —
고른 메일 · 열린 메일 · 스크롤 위치는 그대로. 100통보다 많이 불러왔으면 첫 100통만 맞추고 그 뒤는 둔다.
검색 결과는 다시 찾지 않는다(보던 결과가 저절로 바뀌지 않게).
- 이 탭이 바꾼 것(읽음 · 이동 등)도 값을 바꾸므로 다음 확인에서 한 번 더 받게 된다 — 해롭지 않다.
- 실패(`MAIL_001` · 네트워크)는 알리지 않고(`silent`) 다음 주기에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 그대로 탄다.
- 받은편지함에 안 읽은 메일이 있으면 탭 제목 앞에 `(N) ` 을 붙인다 (디자인 README "3단계").
- 요청 수 한도(`THROTTLE_LIMIT`, 기본 1분 100회)는 경로마다 · IP 마다 센다 — 사무실 전체가 한 IP 로 나가면 보이는 탭이 100개를 넘을 때
1분 확인이 429 `REQ_001` 로 조용히 실패한다(다음 주기에 다시). 값이 바뀌었을 때의 맞추기(`GET /api/messages` · `/api/mailboxes` · `/api/mail-quota`)는
평소 사용과 같은 칸을 쓴다 — `GET /api/messages` 는 대략 "쓰고 있는 탭 수 × 1분에 1번 + 평소 목록 · 더 보기"다. 운영 전환 때 직원 수에 맞춰 정한다.
- 실시간 푸시는 이 단계에서 하지 않는다. 엔진은 JMAP EventSource 를 지원하므로(2026-10-27 확인) 1분이 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 —
그때는 nginx · 외부 프록시의 응답 버퍼링과 시간 제한도 함께 바꿔야 한다.
### 검색 (`GET /api/messages` 의 `q`) — 3단계
- `mailboxId` 가 있으면 그 메일함에서, **없으면 휴지통 · 스팸메일함을 뺀 모든 메일함**에서 찾는다. 둘 다 없으면 400 `VAL_001`.
- `q` 는 앞뒤 공백을 뺀 1~200자(JS 문자열 길이 — UTF-16 단위), 낱말(띄어쓰기로 나눈 것) 8개까지(넘으면 400 `VAL_001`). 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문에서 찾고,
여러 낱말이면 모두 들어간 메일만 찾는다. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다.
낱말 앞뒤의 문장부호는 떼고 찾는다('회의,' · '(회의)' → '회의' — 엔진도 문장부호에서 낱말을 나눈다, 가운데의 문장부호는 둔다).
글자 · 숫자가 하나도 없는 낱말(`-` · 따옴표뿐 등)은 찾지 않고, 찾을 낱말이 하나도 남지 않으면 엔진에 묻지 않고 빈 결과(200)다.
- **한국어 — 서버가 보완한다**: 엔진(Stalwart 0.16.27)의 전문 검색은 띄어쓰기 · 문장부호로 나눈 낱말이 **통째로 같을 때만** 맞는다 —
'회의'로는 '회의를'을 찾지 못한다 (2026-10-27 개발 엔진에서 확인). 그래서 서버는 한글로 끝나는 낱말마다 흔한 조사를 받침에 맞게 붙인 형태를 함께 찾는다
(회의 → 회의가 · 회의를 · 회의는 · 회의에서 · 회의입니다 … / 일정 → 일정이 · 일정을 · 일정은 …, 낱말 안에서는 OR · 낱말끼리는 AND).
영문 · 숫자로 끝나는 낱말(ERP · 2026)은 받침을 알 수 없어 두 가지를 다 붙인다. 따옴표 구절에는 붙이지 않는다.
조사 목록은 서버 상수 한 곳(`jmap/search-terms.ts`)에 둔다 — 한글로 끝나는 낱말은 형태 44개, 영문 · 숫자로 끝나면 59개가 되고,
개발 엔진에서 낱말 8개(조건 472개)를 전체 메일함에서 찾는 데 0.16초였다.
- **알려진 한계** (엔진 색인 때문 — 자체 검색 색인을 두기 전까지): 붙여 쓴 말의 일부('회의실'의 '회의', '서버이전'의 '서버'), 앞부분만 친 말('견적' → '견적서'),
끝이 바뀐 말('보냈습니다'의 '보내'), **제목 맨 앞의 말머리**('[공지]' · '[긴급]' · 'Re:' · 'Fwd:' — 엔진이 제목을 정규화하며 색인에서 뺀다. 제목 가운데의 '[결재]'는 찾는다).
화면은 결과가 없을 때 이 한계를 한 줄로 알린다.
- 결과는 받은 시각 내림차순, `total` 포함. 커서는 그 검색어 · 범위로만 쓴다(다르면 400 `VAL_001`). 발송 중인 내부 초안은 지금처럼 빠진다.
- 검색어는 접근 로그 · 백엔드 로그에 남기지 않는다 — 백엔드는 지금처럼 `maskUrlForLog()`(`q` · `cursor` 는 길이만). 화면 주소에도 검색어가 들어가므로 nginx 는 `/api` 뿐 아니라
**화면 요청의 접근 로그도 쿼리 없이** 남기고(Referer 도 `?` 앞까지), 화면 응답의 `Referrer-Policy` 를 `strict-origin` 으로 둔다 —
검색 화면에서 나가는 요청(1분 확인 등)의 Referer 에 검색어가 실리지 않게. 오류 로그는 장애 원인 줄을 잃지 않게 지금처럼 요청 줄을 쿼리째 남긴다 (docker 스킬 "로그").
- **클라이언트**: 상단 검색 칸(메일 화면에서는 "메일 검색")에서 Enter 로 찾는다 — 치는 동안에는 묻지 않는다.
서버와 같은 한도(200자 · 낱말 8개)를 먼저 보고, 낱말이 8개를 넘으면 보내지 않고 `VAL_001` 사전 문구로 알린다.
검색 화면 안에서 다시 찾거나 범위를 바꾸면 방문 기록을 쌓지 않고 주소를 바꿔 끼운다(메일함에서 시작한 첫 검색만 기록에 쌓인다).
주소는 `/mail/search?q=…&in=<메일함 id | all>`, 결과에서 연 메일은 `/mail/search/<메일 id>?q=…&in=…` (뒤로 가기: 메일 → 결과 → 검색 전 메일함).
범위의 기본값은 검색을 시작한 메일함이고 결과 머리에서 "그 메일함 | 전체 메일함"으로 바꾼다(휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다).
✕ · Esc · "검색 닫기"는 검색 전 메일함으로 돌아간다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 쓴다.
### 여러 통 한꺼번에 (`PATCH /api/messages` · `POST /api/message-deletions`) — 3단계
```ts
// PATCH /api/messages — 읽음 · 중요 표시 · 이동을 여러 통에
interface MessagesPatch {
ids: string[] // 1~100, 같은 id 를 두 번 넣지 않는다
isRead?: boolean
isFlagged?: boolean
mailboxId?: string // 이동 = 그 메일함 하나에만 속하게 (단건 PATCH 와 같다)
} // 바꿀 것이 하나 이상
interface MessagesPatched {
updatedIds: string[]
failedIds: string[] // 없음 · 그사이 지워짐 · 엔진이 거절함 — 나머지는 그대로 처리한다
}
// POST /api/message-deletions — 휴지통에서 영구 삭제, 여러 통
interface MessageDeletionRequest { ids: string[] } // 1~100, 같은 id 를 두 번 넣지 않는다
interface MessageDeletionResult {
deletedIds: string[] // 영구 삭제했다(휴지통에만 있었다) · 휴지통에서만 뺐다(다른 메일함에도 있었다)
skippedIds: string[] // 휴지통에 없음 · 없음 · 엔진이 거절 — 지우지 않았다
}
```
- `PATCH /api/messages` 는 엔진 요청 한 번으로 처리한다. 없는 `mailboxId` 는 404 `RES_001` 이고 아무것도 바꾸지 않는다.
503 `MAIL_001` 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등).
- `POST /api/message-deletions` 는 **휴지통에 있는 메일만** 다룬다 — `DELETE /api/messages/{id}` 와 달리 휴지통 밖의 메일을 휴지통으로 옮기지 않는다.
대상을 확인한 뒤 확인한 상태 그대로일 때만 한 번에 지운다(`ifInState` — 그사이 다른 창이 휴지통 밖으로 되돌린 메일을 지우지 않는다).
어긋나면 다시 확인해 합계 `CONDITIONAL_DESTROY_ATTEMPTS` 번, 계속 어긋나면 **아무것도 지우지 않고** 503 `MAIL_001`. 휴지통이 없어도 503 `MAIL_001`.
지우는 요청이 엔진 시간 초과로 끝난 503 `MAIL_001` 만은 일부가 지워졌을 수 있다 — 같은 요청을 다시 보내면 이미 지운 메일은 `skippedIds` 로 온다.
한 번에 지우기(destroy)와 휴지통에서 빼기(update)를 함께 보내고, 옛 상태면 엔진이 둘 다 거절한다 (2026-10-27 개발 엔진에서 확인).
- 한 요청의 `ids` 는 100개까지다 (엔진의 한 번 처리 한도 500 보다 작게).
- **클라이언트**:
- 고를 수 있는 것은 불러온 메일까지다 (표 머리의 고르기 = 불러온 메일 모두). 100통이 넘으면 100통씩 차례로 보낸다.
- 휴지통 밖의 「삭제」는 `PATCH { mailboxId: 휴지통 }` 이다 — 「되돌리기」는 원래 메일함별로 묶어 다시 `PATCH`.
`POST /api/message-deletions` 는 휴지통에서 「영구 삭제」를 확인한 뒤에만 부른다.
- **웹 클라이언트는 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다.
읽기 화면의 「삭제」도 그 메일의 `mailboxIds` 를 보고 휴지통 밖이면 휴지통으로 `PATCH`(+ 되돌리기), 휴지통이면 확인 대화상자 뒤 `POST /api/message-deletions` 다.
휴지통 목록 밖(검색 결과 등)에서는 **휴지통에만 있는** 메일만 「영구 삭제」다 — 휴지통과 다른 메일함에 함께 있는 메일은 「삭제」(휴지통으로 옮기기)로 다룬다
(영구 삭제를 확인했는데 서버가 휴지통에서만 빼고 다른 메일함에 남기는 일이 없게).
`mailboxIds` 가 낡았어도 해가 없다 — 이미 휴지통인 메일을 휴지통으로 옮기면 그대로이고, 휴지통 밖으로 돌아간 메일은 서버가 `skippedIds` 로 건너뛴다.
- **알려진 한계**: 「되돌리기」는 이 화면이 알던 메일함 한 곳으로 되돌린다 — 그사이 다른 창이 옮긴 메일(저절로 바뀌지 않는 검색 결과 등)은 그 전 메일함으로,
여러 메일함에 함께 있던 메일(다른 메일 프로그램의 복사)은 첫 메일함으로만 돌아간다.
- 「되돌리기」 알림은 만든 세션에서만 움직인다 — 로그아웃 · 세션 만료 · 다른 계정 로그인 뒤에는 아무것도 하지 않는다(메일 id 는 계정마다 따로 매겨져 겹칠 수 있다).
- 읽음 · 중요 표시는 화면을 먼저 바꾸고 `failedIds` 만 되돌린다. 이동 · 삭제는 응답을 받은 뒤 `updatedIds` · `deletedIds` 만 목록에서 빼고 개수를 다시 맞춘다
(전체 메일함 검색 결과에서 범위 안의 다른 메일함으로 옮긴 메일은 여전히 찾은 메일이라 빼지 않고 메일함 표시만 바꾼다 — 휴지통 · 스팸메일함으로 옮기면 뺀다).
요청이 통째로 실패해 하나도 처리하지 못하면 고른 것을 그대로 둔다(다시 시도할 수 있게).
- `failedIds` · `skippedIds` 가 있으면 알림에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다"를 덧붙인다.
- 메일함을 바꾸거나 검색하면 선택을 푼다. 새 메일 확인으로 목록을 맞출 때는 남아 있는 메일의 선택을 그대로 둔다.
### 이 단계에서 하지 않는 것
실시간 새 메일 알림(SSE), 검색 화면, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리,
여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록.
실시간 푸시(SSE · 데스크톱 알림 · 소리), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
불러오지 않은 메일까지 한꺼번에(메일함 전체 읽음 표시 · 휴지통 비우기), 끌어다 놓아 옮기기.