docs: 7단계(이미 받은 메일에 필터 적용) 계약과 디자인

필터를 저장할 때 받은편지함에 이미 있는 메일에도 그 필터 하나를 적용한다. 엔진의 Sieve 는 받을 때만
돌고 엔진 검색은 낱말 단위라, 서버가 받은편지함을 읽어 Sieve 와 같은 규칙으로 판정한다.

- 계약 2절 "이미 받은 메일에 적용"(POST /api/mail-filter-runs): 판정 규칙은 실엔진 배달로 맞췄다 —
  HTML 은 닫는 p · br 만 줄바꿈, style · script · head · template 은 글째 빼고, 속성이 붙은 태그는
  엔진처럼 알아보지 않는다. 머리글은 접힌 줄을 띄어쓰기 하나로, 인코딩된 글은 홀로 선 것만 푼다
- 시작한 때 받은편지함에 있던 메일만 보고(위 경계), 마지막으로 남긴 메일이 빠지면 그 초부터 다시 읽는다.
  본문 값은 한 통 1MiB 까지 — 바깥 메일 한 통으로 서버가 메모리를 크게 쓰지 않게
- 부재중 응답에 isActive(엔진에서 우리 규칙이 꺼졌는지), 엔진의 임시 업로드 한도, 바깥 주소 자동 전달은
  허용하지 않는다는 정책(2026-10-29 결정)
- 디자인 캔버스 확정본 MailFilterApply · MailFilterApplyStates 와 README "7단계"(구현하며 정한 보완 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 08:03:42 +09:00
parent 188b9e84f4
commit 5ddc849c11
5 changed files with 896 additions and 11 deletions
+561
View File
@@ -0,0 +1,561 @@
<!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}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:hover{background:#0A5F67}
.ghost{background:transparent;color:#4A5363}
.ghost:hover{background:#F2F4F7;color:#171C26}
.ghost:disabled{background:transparent;color:#98A2B3;cursor:not-allowed}
.danger{background:transparent;color:#B42318}
.danger:hover{background:#FEF3F2;color:#912018}
.menu-link{color:#344054}
.menu-link:hover{background:#EEF0F3}
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
.field:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
.field:disabled{background:#F8F9FB;color:#98A2B3}
.field.is-error{border-color:#B42318}
.field.is-error:focus{box-shadow:0 0 0 1px #B42318}
.rule-main:hover{background:#F8F9FB}
.icon-btn{background:transparent;color:#4A5363}
.icon-btn:hover{background:#F2F4F7;color:#171C26}
.icon-btn:disabled{background:transparent;color:#D0D5DD;cursor:default}
.toast-action{background:transparent;color:#9ED6DB;text-decoration:none}
.toast-action:hover{background:rgba(255,255,255,0.1);color:#9ED6DB}
</style>
</helmet>
<div style="position: relative; min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
</a>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
<li>
<a href="Main.dc.html" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>메일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>캘린더</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span>파일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>
<span>ERP</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span>주소록</span>
</a>
</li>
</ul>
<a href="Settings.dc.html" aria-current="page" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>설정</span>
</a>
</nav>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">설정</span>
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" aria-label="통합 검색" placeholder="통합 검색" style="flex: 1 1 auto; min-width: 0; border: 0; background: transparent; font: inherit; color: #171C26">
</div>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</a>
</div>
</header>
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
<nav aria-label="설정 메뉴" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
<p style="margin: 4px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">계정</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="SettingsAccount.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
<span>비밀번호</span>
</a></li>
</ul>
<p style="margin: 14px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">메일</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="Settings.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>서명</span>
</a></li>
<li><a href="SettingsVacation.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><line x1="10" y1="14" x2="14" y2="18"></line><line x1="14" y1="14" x2="10" y2="18"></line></svg>
<span>부재중 자동 응답</span>
</a></li>
<li><a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
<span>필터</span>
</a></li>
<li><a href="SettingsNotify.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
<span>알림</span>
</a></li>
</ul>
</nav>
<main style="flex: 999 1 560px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
<div style="max-width: 880px; box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 18px">
<div style="display: flex; align-items: flex-start; flex-wrap: wrap; gap: 12px 20px">
<div style="flex: 1 1 360px; display: flex; flex-direction: column; gap: 4px">
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">필터</h1>
<p style="margin: 0; color: #4A5363">받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에 적용하고, 고를 때는 받은편지함에 이미 있는 메일에도 적용합니다.</p>
</div>
<button type="button" class="primary" onClick="{{ openNew }}" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>새 필터</span>
</button>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<ol aria-label="필터 — 위에서부터 차례로 적용" style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<sc-for list="{{ rules }}" as="r" hint-placeholder-count="5">
<li class="rule-row" style="display: grid; grid-template-columns: 22px 40px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 60px; box-sizing: border-box; padding: 6px 8px 6px 14px; border-bottom: {{ r.border }}; background: #FFFFFF">
<span style="font-size: 12px; color: #98A2B3; font-variant-numeric: tabular-nums; text-align: right">{{ r.no }}</span>
<button type="button" role="switch" aria-checked="{{ r.onStr }}" aria-label="{{ r.switchLabel }}" onClick="{{ r.toggle }}" style="position: relative; width: 36px; height: 20px; padding: 0; border: 0; border-radius: 10px; background: {{ r.trackBg }}; cursor: pointer">
<span aria-hidden="true" style="position: absolute; top: 2px; left: {{ r.knobLeft }}px; width: 16px; height: 16px; border-radius: 50%; background: #FFFFFF; box-shadow: 0 1px 2px rgba(16, 24, 40, 0.24)"></span>
</button>
<button type="button" class="rule-main" onClick="{{ r.edit }}" aria-label="{{ r.editLabel }}" style="min-width: 0; display: flex; flex-direction: column; gap: 2px; padding: 6px 8px; border: 0; border-radius: 6px; background: transparent; font: inherit; text-align: left; cursor: pointer">
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: {{ r.titleColor }}">{{ r.title }}</span>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: {{ r.subColor }}">{{ r.sub }}</span>
</button>
<div style="display: flex; align-items: center; gap: 2px">
<button type="button" class="icon-btn" onClick="{{ r.up }}" disabled="{{ r.first }}" aria-label="{{ r.upLabel }}" style="width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; 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"><polyline points="18 15 12 9 6 15"></polyline></svg>
</button>
<button type="button" class="icon-btn" onClick="{{ r.down }}" disabled="{{ r.last }}" aria-label="{{ r.downLabel }}" style="width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; 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"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
</div>
</li>
</sc-for>
</ol>
<p style="margin: 0; font-size: 12.5px; color: #667085">필터 {{ count }}개 · 50개까지 만들 수 있습니다. 행을 누르면 고칩니다.</p>
</div>
<div style="display: flex; flex-direction: column; gap: 6px; box-sizing: border-box; max-width: 700px; padding: 14px 16px; border-radius: 10px; background: #F3F8F8; color: #344054">
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span><strong style="font-weight: 600">위에서부터 차례로</strong> 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span>이미 받은 메일에도 적용하려면 필터를 저장할 때 <strong style="font-weight: 600">「받은편지함에 이미 있는 메일에도 적용」</strong>을 고릅니다. 받은편지함의 메일만 보고, 그 필터 하나만 적용합니다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span>스팸으로 판정되어 들어오는 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span>전달은 사내 주소로만, 새로 오는 메일만 합니다. 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span>부재중 자동 응답은 필터와 함께 동작합니다.</span></p>
</div>
</div>
</main>
</div>
</div>
<sc-if value="{{ editing }}" 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="dialog" aria-modal="true" aria-labelledby="flt-title" style="width: min(620px, 100%); max-height: calc(100vh - 48px); overflow: auto; box-sizing: border-box; padding: 18px 20px 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: 16px">
<h3 id="flt-title" style="margin: 0; font-size: 15px; font-weight: 700">{{ dialogTitle }}</h3>
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="flt-name" style="font-size: 12.5px; font-weight: 600; color: #4A5363">이름 <span style="font-weight: 400; color: #667085">(선택)</span></label>
<input id="flt-name" class="field" type="text" value="{{ d.name }}" onChange="{{ setName }}" maxlength="50" placeholder="비워 두면 조건이 이름이 됩니다" style="height: 44px; padding: 0 12px">
</div>
<fieldset style="display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 6px; padding: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">어떤 메일에</legend>
<select class="field" aria-label="어떤 메일에" onChange="{{ setMatch }}" style="height: 44px; padding: 0 10px; max-width: 320px">
<option value="all" selected="{{ matchAll }}">다음 조건이 모두 맞는 메일</option>
<option value="any" selected="{{ matchAny }}">다음 조건 가운데 하나라도 맞는 메일</option>
<option value="always" selected="{{ matchAlways }}">모든 메일</option>
</select>
<sc-if value="{{ hasConds }}" hint-placeholder-val="{{ true }}">
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px">
<sc-for list="{{ conds }}" as="c" hint-placeholder-count="1">
<li style="display: grid; grid-template-columns: 116px 132px minmax(0, 1fr) 36px; align-items: center; gap: 8px">
<select class="field" aria-label="{{ c.fieldLabel }}" onChange="{{ c.onField }}" style="height: 40px; padding: 0 8px">
<option value="from" selected="{{ c.isFrom }}">보낸 사람</option>
<option value="to" selected="{{ c.isTo }}">받는 사람</option>
<option value="subject" selected="{{ c.isSubject }}">제목</option>
<option value="body" selected="{{ c.isBody }}">본문</option>
</select>
<select class="field" aria-label="{{ c.opLabel }}" onChange="{{ c.onOp }}" style="height: 40px; padding: 0 8px">
<option value="has" selected="{{ c.isHas }}">포함</option>
<option value="not" selected="{{ c.isNot }}">포함하지 않음</option>
</select>
<input class="{{ c.inputClass }}" type="text" value="{{ c.v }}" onChange="{{ c.onValue }}" aria-label="{{ c.valueLabel }}" aria-invalid="{{ c.invalid }}" maxlength="200" placeholder="{{ c.placeholder }}" style="height: 40px; padding: 0 10px; min-width: 0">
<button type="button" class="icon-btn" onClick="{{ c.remove }}" disabled="{{ c.onlyOne }}" aria-label="{{ c.removeLabel }}" style="width: 36px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; 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>
</li>
</sc-for>
</ul>
<div style="display: flex; align-items: center; gap: 10px">
<button type="button" class="ghost" onClick="{{ addCond }}" disabled="{{ condFull }}" style="display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 7px; 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="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>조건 더하기</span>
</button>
<span style="font-size: 12px; color: #667085">5개까지 · 글자는 대소문자를 가리지 않습니다</span>
</div>
</sc-if>
<sc-if value="{{ condError }}" hint-placeholder-val="{{ false }}">
<p role="alert" style="margin: 0; font-size: 12.5px; color: #B42318">비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.</p>
</sc-if>
</fieldset>
<fieldset style="display: flex; flex-direction: column; gap: 2px; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 6px; padding: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">할 일</legend>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; min-height: 44px">
<label style="display: flex; align-items: center; gap: 10px; min-width: 176px; cursor: pointer">
<input type="checkbox" checked="{{ hasMove }}" onChange="{{ toggleMove }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span>메일함으로 옮기기</span>
</label>
<select class="field" aria-label="옮길 메일함" disabled="{{ noMove }}" onChange="{{ setMove }}" style="height: 40px; padding: 0 10px; min-width: 200px">
<option value="inbox" selected="{{ selInbox }}">받은편지함</option>
<option value="vendor" selected="{{ selVendor }}">거래처</option>
<option value="notice" selected="{{ selNotice }}">전체 공지</option>
<option value="project" selected="{{ selProject }}">프로젝트</option>
<option value="junk" selected="{{ selJunk }}">스팸메일함</option>
<option value="trash" selected="{{ selTrash }}">휴지통</option>
</select>
</div>
<p style="margin: 0 0 4px 28px; font-size: 12.5px; color: #667085">옮기면 이 아래 필터는 적용하지 않습니다.</p>
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
<input type="checkbox" checked="{{ d.read }}" onChange="{{ toggleRead }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span>읽음으로 표시</span>
</label>
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
<input type="checkbox" checked="{{ d.flag }}" onChange="{{ toggleFlag }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span>중요 표시</span>
</label>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; min-height: 44px">
<label style="display: flex; align-items: center; gap: 10px; min-width: 176px; cursor: pointer">
<input type="checkbox" checked="{{ hasFwd }}" onChange="{{ toggleFwd }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span>동료에게 전달</span>
</label>
<input class="{{ fwdClass }}" type="text" value="{{ d.fwd }}" onChange="{{ setFwd }}" disabled="{{ noFwd }}" aria-label="전달할 사내 주소" aria-invalid="{{ fwdInvalid }}" placeholder="이름@arasystem.it.kr" style="flex: 1 1 240px; height: 40px; padding: 0 10px; max-width: 320px">
</div>
<p style="margin: 0 0 0 28px; font-size: 12.5px; color: {{ fwdHelpColor }}">{{ fwdHelp }}</p>
<sc-if value="{{ actionError }}" hint-placeholder-val="{{ false }}">
<p role="alert" style="margin: 4px 0 0; font-size: 12.5px; color: #B42318">할 일을 하나 이상 골라 주세요.</p>
</sc-if>
</fieldset>
<div style="display: flex; flex-direction: column; gap: 2px; padding: 6px 12px 10px; border-radius: 8px; background: #F8F9FB">
<label style="display: flex; align-items: center; gap: 10px; min-height: 40px; cursor: {{ applyCursor }}">
<input type="checkbox" checked="{{ applyOn }}" disabled="{{ applyOff }}" onChange="{{ toggleApply }}" aria-describedby="flt-apply-help" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: inherit">
<span style="font-weight: 600; color: {{ applyColor }}">받은편지함에 이미 있는 메일에도 적용</span>
</label>
<p id="flt-apply-help" style="margin: 0 0 0 28px; font-size: 12.5px; color: #667085">{{ applyHelp }}</p>
</div>
<p style="margin: -6px 0 0; font-size: 12.5px; color: #667085">스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.</p>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px">
<sc-if value="{{ canDelete }}" hint-placeholder-val="{{ true }}">
<button type="button" class="danger" onClick="{{ remove }}" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
<span>이 필터 지우기</span>
</button>
</sc-if>
<div style="margin-left: auto; display: flex; gap: 8px">
<button type="button" class="primary" onClick="{{ save }}" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">저장</button>
<button type="button" class="ghost" onClick="{{ cancel }}" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ confirming }}" hint-placeholder-val="{{ false }}">
<div style="position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(16, 24, 28, 0.24)">
<div role="alertdialog" aria-modal="true" aria-labelledby="flt-leave-title" aria-describedby="flt-leave-message" style="width: min(400px, 100%); box-sizing: border-box; padding: 20px 20px 16px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; color: #171C26; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.18); display: flex; flex-direction: column; gap: 8px">
<h3 id="flt-leave-title" style="margin: 0; font-size: 15px; font-weight: 700">저장하지 않은 필터가 있습니다</h3>
<p id="flt-leave-message" style="margin: 0; color: #4A5363">창을 닫으면 고친 내용이 사라집니다.</p>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px">
<button type="button" class="primary" onClick="{{ stay }}" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">계속 고치기</button>
<button type="button" class="ghost" onClick="{{ discard }}" 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="{{ hasRun }}" hint-placeholder-val="{{ false }}">
<div role="status" style="position: fixed; right: 20px; bottom: 20px; z-index: 20; width: min(460px, calc(100vw - 40px)); box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M21 12a9 9 0 1 1-6.22-8.56"></path></svg>
<span style="flex: 1 1 auto; font-weight: 500">{{ runText }} · <span style="font-variant-numeric: tabular-nums">{{ runCount }}</span></span>
</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(460px, calc(100vw - 40px)); box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<sc-if value="{{ toastOk }}" hint-placeholder-val="{{ true }}">
<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>
</sc-if>
<sc-if value="{{ toastFail }}" hint-placeholder-val="{{ false }}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#FDA29B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
</sc-if>
<span style="flex: 1 1 auto; font-weight: 500">{{ toastText }}</span>
<sc-if value="{{ toastLink }}" hint-placeholder-val="{{ false }}">
<a href="Main.dc.html" class="on-dark toast-action" style="display: inline-flex; align-items: center; min-height: 36px; padding: 0 10px; border-radius: 7px; font-weight: 600">{{ toastLinkLabel }}</a>
</sc-if>
<sc-if value="{{ toastButton }}" hint-placeholder-val="{{ false }}">
<button type="button" class="on-dark toast-action" onClick="{{ toastAct }}" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 600; cursor: pointer">{{ toastButtonLabel }}</button>
</sc-if>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"applyOutcome":{"editor":"enum","options":["applied","none","stopped"],"default":"applied","section":"받은편지함에 적용한 결과"},"$preview":{"width":1440,"height":900}}'>
class Component extends DCLogic {
state = {
rules: [
{ id: 'r1', name: '휴가 중 전달', on: false, match: 'always', conds: [], move: null, read: false, flag: false, fwd: 'doyun.lee@arasystem.it.kr' },
{ id: 'r2', name: '거래처 메일', on: true, match: 'all', conds: [{ f: 'from', op: 'has', v: '@hanbit-partners.co.kr' }], move: 'vendor', read: false, flag: false, fwd: '' },
{ id: 'r3', name: '결재 알림', on: true, match: 'all', conds: [{ f: 'subject', op: 'has', v: '[결재]' }], move: null, read: true, flag: true, fwd: '' },
{ id: 'r4', name: '', on: false, match: 'all', conds: [{ f: 'to', op: 'has', v: 'all@arasystem.it.kr' }], move: 'notice', read: true, flag: false, fwd: '' },
{ id: 'r5', name: '알파 프로젝트', on: true, match: 'any', conds: [{ f: 'subject', op: 'has', v: '알파' }, { f: 'body', op: 'has', v: 'Project Alpha' }], move: 'project', read: false, flag: false, fwd: '' },
],
edit: null,
confirm: false,
toast: null,
run: null,
};
componentWillUnmount() {
clearTimeout(this.timer);
}
// 받은편지함 적용 흉내 — 서버가 한 요청에 수천 통씩 확인하고 남은 수를 돌려주면 알림의 숫자가 는다
step() {
clearTimeout(this.timer);
this.timer = setTimeout(() => {
const run = this.state.run;
if (!run) return;
const done = Math.min(run.total, run.done + 1400);
// [다시 시도]로 이어 하면 남은 메일부터 끝까지 간다
const outcome = this.resumed ? 'applied' : (this.props.applyOutcome ?? 'applied');
const num = (n) => n.toLocaleString('ko-KR');
if (outcome === 'stopped' && done >= 4200) {
this.setState({ run: null, toast: { kind: 'fail', text: `‘${run.name}’ 필터 — ${num(run.total)}통 가운데 ${num(done)}통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요.`, button: '다시 시도', resume: { ...run, done } } });
return;
}
if (done >= run.total) {
const none = outcome === 'none';
this.setState({ run: null, toast: {
kind: 'ok',
text: none ? `받은편지함에 ‘${run.name}’ 필터에 맞는 메일이 없습니다.` : `‘${run.name}’ 필터를 받은편지함의 메일 37통에 적용했습니다.`,
link: !none && run.targetName ? `${run.targetName} 보기` : null,
} });
return;
}
this.setState({ run: { ...run, done } });
this.step();
}, 650);
}
renderVals() {
const st = this.state;
const BOXES = [
{ id: 'inbox', name: '받은편지함' }, { id: 'vendor', name: '거래처' }, { id: 'notice', name: '전체 공지' },
{ id: 'project', name: '프로젝트' }, { id: 'junk', name: '스팸메일함' }, { id: 'trash', name: '휴지통' },
];
const FIELD = { from: '보낸 사람', to: '받는 사람', subject: '제목', body: '본문' };
const HINT = { from: '예: @hanbit-partners.co.kr 또는 이름', to: '예: all@arasystem.it.kr', subject: '예: [결재]', body: '예: 견적서' };
const DOMAIN = '@arasystem.it.kr';
const num = (n) => n.toLocaleString('ko-KR');
const boxName = (id) => (BOXES.find((b) => b.id === id) || {}).name;
const condText = (c) => `${FIELD[c.f]}에 ‘${c.v}’ ${c.op === 'has' ? '포함' : '포함하지 않음'}`;
const condsText = (r) => (r.match === 'always' ? '모든 메일' : r.conds.map(condText).join(r.match === 'all' ? ' 그리고 ' : ' 또는 '));
const actText = (r) => [
r.move ? `옮기기: ${boxName(r.move) || '없는 메일함'}` : null,
r.read ? '읽음으로 표시' : null,
r.flag ? '중요 표시' : null,
r.fwd ? `전달: ${r.fwd}` : null,
].filter(Boolean).join(' · ');
const titleOf = (r) => r.name || condsText(r);
const setRules = (rules, toast) => this.setState({ rules, toast: toast === undefined ? st.toast : toast });
const move = (i, by) => {
const next = st.rules.slice();
const [it] = next.splice(i, 1);
next.splice(i + by, 0, it);
setRules(next, null);
};
const n = st.rules.length;
const rules = st.rules.map((r, i) => ({
no: i + 1,
border: i === n - 1 ? '0' : '1px solid #EEF0F3',
onStr: r.on ? 'true' : 'false',
switchLabel: `‘${titleOf(r)}’ 필터 ${r.on ? '끄기' : '켜기'}`,
trackBg: r.on ? '#0E7C86' : '#D0D5DD',
knobLeft: r.on ? 18 : 2,
toggle: () => setRules(st.rules.map((x, k) => (k === i ? { ...x, on: !x.on } : x)), null),
edit: () => {
const d = { ...JSON.parse(JSON.stringify(r)), apply: false };
this.setState({ edit: { index: i, d, initial: JSON.stringify(d), error: null }, toast: st.run ? st.toast : null });
},
editLabel: `‘${titleOf(r)}’ 필터 고치기`,
title: titleOf(r),
sub: r.name ? `${condsText(r)} → ${actText(r)}` : `→ ${actText(r)}`,
titleColor: r.on ? '#171C26' : '#667085',
subColor: r.on ? '#4A5363' : '#98A2B3',
up: () => move(i, -1),
down: () => move(i, 1),
first: i === 0,
last: i === n - 1,
upLabel: `‘${titleOf(r)}’ 위로`,
downLabel: `‘${titleOf(r)}’ 아래로`,
}));
const e = st.edit;
const blank = { name: '', match: 'all', conds: [{ f: 'from', op: 'has', v: '' }], move: null, read: false, flag: false, fwd: '', apply: false };
const d = e ? e.d : blank;
const patch = (p) => this.setState({ edit: { ...e, d: { ...d, ...p }, error: null } });
const setCond = (k, p) => patch({ conds: d.conds.map((c, j) => (j === k ? { ...c, ...p } : c)) });
const fwdBad = Boolean(e && e.error === 'fwd');
const conds = d.conds.map((c, k) => ({
...c,
isFrom: c.f === 'from', isTo: c.f === 'to', isSubject: c.f === 'subject', isBody: c.f === 'body',
isHas: c.op === 'has', isNot: c.op === 'not',
fieldLabel: `조건 ${k + 1} — 어디에서`, opLabel: `조건 ${k + 1} — 포함 · 포함하지 않음`, valueLabel: `조건 ${k + 1} — 찾을 글`, removeLabel: `조건 ${k + 1} 지우기`,
placeholder: HINT[c.f],
invalid: e && e.error === 'cond' && !c.v.trim() ? 'true' : 'false',
inputClass: e && e.error === 'cond' && !c.v.trim() ? 'field is-error' : 'field',
onField: (ev) => setCond(k, { f: ev.target.value }),
onOp: (ev) => setCond(k, { op: ev.target.value }),
onValue: (ev) => setCond(k, { v: ev.target.value }),
remove: () => patch({ conds: d.conds.filter((_, j) => j !== k) }),
onlyOne: d.conds.length === 1,
}));
// 「받은편지함에 이미 있는 메일에도 적용」 — 전달만 하는 필터 · 다른 필터를 적용하는 중이면 고를 수 없다
const fwdSet = d.fwd !== '' || Boolean(e && e.fwdOn);
const forwardOnly = fwdSet && !d.move && !d.read && !d.flag;
const busy = Boolean(st.run);
const applyOff = forwardOnly || busy;
const applyHelp = busy
? '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다.'
: forwardOnly
? '전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다.'
: `저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다.${fwdSet ? ' 전달은 새로 오는 메일에만 합니다.' : ''}`;
const save = () => {
if (d.match !== 'always' && d.conds.some((c) => !c.v.trim())) return this.setState({ edit: { ...e, error: 'cond' } });
if (!d.move && !d.read && !d.flag && !d.fwd) return this.setState({ edit: { ...e, error: 'action' } });
if (d.fwd && !d.fwd.trim().toLowerCase().endsWith(DOMAIN)) return this.setState({ edit: { ...e, error: 'fwd' } });
const { apply, ...rest } = d;
const rule = { ...rest, conds: d.match === 'always' ? [] : d.conds, fwd: d.fwd.trim() };
const next = e.index === null ? [...st.rules, { ...rule, id: `r${Date.now()}`, on: true }] : st.rules.map((x, k) => (k === e.index ? { ...x, ...rule } : x));
if (apply && !applyOff) {
this.resumed = false;
this.setState({ rules: next, edit: null, confirm: false, toast: null, run: { name: titleOf(rule), done: 0, total: 8412, targetName: rule.move && rule.move !== 'inbox' ? boxName(rule.move) : null } });
this.step();
return undefined;
}
this.setState({ rules: next, edit: null, confirm: false, toast: { kind: 'ok', text: '필터를 저장했습니다.' } });
return undefined;
};
const dirty = Boolean(e) && JSON.stringify(d) !== e.initial;
const removed = e && e.index !== null ? st.rules[e.index] : null;
const t = st.toast;
return {
rules,
count: n,
openNew: () => this.setState({ edit: { index: null, d: blank, initial: JSON.stringify(blank), error: null }, toast: st.run ? st.toast : null }),
editing: Boolean(e),
dialogTitle: e && e.index !== null ? '필터 고치기' : '새 필터',
d,
setName: (ev) => patch({ name: ev.target.value }),
setMatch: (ev) => patch({ match: ev.target.value, conds: ev.target.value !== 'always' && d.conds.length === 0 ? [{ f: 'from', op: 'has', v: '' }] : d.conds }),
matchAll: d.match === 'all', matchAny: d.match === 'any', matchAlways: d.match === 'always',
hasConds: d.match !== 'always',
conds,
addCond: () => patch({ conds: [...d.conds, { f: 'subject', op: 'has', v: '' }] }),
condFull: d.conds.length >= 5,
condError: Boolean(e && e.error === 'cond'),
hasMove: Boolean(d.move), noMove: !d.move,
toggleMove: (ev) => patch({ move: ev.target.checked ? 'vendor' : null }),
setMove: (ev) => patch({ move: ev.target.value }),
selInbox: d.move === 'inbox', selVendor: d.move === 'vendor' || !d.move, selNotice: d.move === 'notice', selProject: d.move === 'project', selJunk: d.move === 'junk', selTrash: d.move === 'trash',
toggleRead: (ev) => patch({ read: ev.target.checked }),
toggleFlag: (ev) => patch({ flag: ev.target.checked }),
hasFwd: fwdSet, noFwd: !fwdSet,
toggleFwd: (ev) => this.setState({ edit: { ...e, fwdOn: ev.target.checked, d: { ...d, fwd: ev.target.checked ? d.fwd : '' }, error: null } }),
setFwd: (ev) => patch({ fwd: ev.target.value }),
fwdClass: fwdBad ? 'field is-error' : 'field',
fwdInvalid: fwdBad ? 'true' : 'false',
fwdHelp: fwdBad ? '사내 주소(@arasystem.it.kr)로만 전달할 수 있습니다.' : '사내 주소로만 전달할 수 있습니다. 전달해도 내 메일함에 그대로 남습니다.',
fwdHelpColor: fwdBad ? '#B42318' : '#667085',
actionError: Boolean(e && e.error === 'action'),
applyOn: Boolean(d.apply) && !applyOff,
applyOff,
applyColor: applyOff ? '#98A2B3' : '#171C26',
applyCursor: applyOff ? 'default' : 'pointer',
applyHelp,
toggleApply: (ev) => patch({ apply: ev.target.checked }),
canDelete: Boolean(e && e.index !== null),
remove: () => this.setState({ rules: st.rules.filter((_, k) => k !== e.index), edit: null, toast: { kind: 'ok', text: `‘${titleOf(removed)}’ 필터를 지웠습니다.`, button: '되돌리기', undo: st.rules } }),
save,
cancel: () => (dirty ? this.setState({ confirm: true }) : this.setState({ edit: null })),
confirming: Boolean(e) && st.confirm,
stay: () => this.setState({ confirm: false }),
discard: () => this.setState({ confirm: false, edit: null }),
hasRun: Boolean(st.run),
runText: st.run ? `‘${st.run.name}’ 필터를 받은편지함에 적용하는 중` : '',
runCount: st.run ? `${num(st.run.done)} / ${num(st.run.total)}통 확인` : '',
hasToast: !st.run && Boolean(t),
toastOk: Boolean(t && t.kind === 'ok'),
toastFail: Boolean(t && t.kind === 'fail'),
toastText: t ? t.text : '',
toastLink: Boolean(t && t.link),
toastLinkLabel: t && t.link ? t.link : '',
toastButton: Boolean(t && t.button),
toastButtonLabel: t && t.button ? t.button : '',
toastAct: () => {
if (t && t.undo) return this.setState({ rules: t.undo, toast: { kind: 'ok', text: '필터를 되돌렸습니다.' } });
if (t && t.resume) {
this.resumed = true;
this.setState({ toast: null, run: { ...t.resume } });
this.step();
}
return undefined;
},
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,178 @@
<!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}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:hover{background:#0A5F67}
.ghost{background:transparent;color:#4A5363}
.ghost:hover{background:#F2F4F7;color:#171C26}
.toast-action{background:transparent;color:#9ED6DB;text-decoration:none}
.toast-action:hover{background:rgba(255,255,255,0.1);color:#9ED6DB}
</style>
</helmet>
<div style="width: 1440px; min-height: 1280px; 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: 980px">
<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">7단계에서 새로 생기는 것입니다. 「설정 · 필터 · 이미 받은 메일에 적용」 보드에서 필터를 열고 「받은편지함에 이미 있는 메일에도 적용」을 골라 저장해 볼 수 있습니다(오른쪽 위 Tweaks 에서 결과를 바꿉니다). 이 보드는 그 다른 상태와 함께 고치는 두 가지 — 고치기 창의 나가기 확인, 부재중 화면의 안내 — 를 모았습니다.</p>
</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">고치기 창 — 「받은편지함에 이미 있는 메일에도 적용」</h2>
<p style="margin: 0; font-size: 12px; color: #667085">할 일 아래 · 창을 열 때마다 꺼져 있다 · 저장할 때 한 번 쓰고 기억하지 않는다(필터의 설정이 아니다)</p>
</div>
<div style="display: flex; flex-direction: column; gap: 4px">
<p style="margin: 0; font-size: 12px; font-weight: 600; color: #667085">기본</p>
<div style="display: flex; flex-direction: column; gap: 2px; padding: 6px 12px 10px; border-radius: 8px; background: #F8F9FB">
<label style="display: flex; align-items: center; gap: 10px; min-height: 40px; cursor: pointer">
<input type="checkbox" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span style="font-weight: 600">받은편지함에 이미 있는 메일에도 적용</span>
</label>
<p style="margin: 0 0 0 28px; font-size: 12.5px; color: #667085">저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다.</p>
</div>
<p style="margin: 2px 0 0; font-size: 12.5px; color: #667085">스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.</p>
</div>
<div style="display: flex; flex-direction: column; gap: 4px">
<p style="margin: 0; font-size: 12px; font-weight: 600; color: #667085">고름 — 전달도 하는 필터</p>
<div style="display: flex; flex-direction: column; gap: 2px; padding: 6px 12px 10px; border-radius: 8px; background: #F8F9FB">
<label style="display: flex; align-items: center; gap: 10px; min-height: 40px; cursor: pointer">
<input type="checkbox" checked style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span style="font-weight: 600">받은편지함에 이미 있는 메일에도 적용</span>
</label>
<p style="margin: 0 0 0 28px; font-size: 12.5px; color: #667085">저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다. 전달은 새로 오는 메일에만 합니다.</p>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 4px">
<p style="margin: 0; font-size: 12px; font-weight: 600; color: #667085">고를 수 없음 — 전달만 하는 필터</p>
<div style="display: flex; flex-direction: column; gap: 2px; padding: 6px 12px 10px; border-radius: 8px; background: #F8F9FB">
<label style="display: flex; align-items: center; gap: 10px; min-height: 40px">
<input type="checkbox" disabled style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
<span style="font-weight: 600; color: #98A2B3">받은편지함에 이미 있는 메일에도 적용</span>
</label>
<p style="margin: 0 0 0 28px; font-size: 12.5px; color: #667085">전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다.</p>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 4px">
<p style="margin: 0; font-size: 12px; font-weight: 600; color: #667085">고를 수 없음 — 다른 필터를 적용하는 중</p>
<div style="display: flex; flex-direction: column; gap: 2px; padding: 6px 12px 10px; border-radius: 8px; background: #F8F9FB">
<label style="display: flex; align-items: center; gap: 10px; min-height: 40px">
<input type="checkbox" disabled style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
<span style="font-weight: 600; color: #98A2B3">받은편지함에 이미 있는 메일에도 적용</span>
</label>
<p style="margin: 0 0 0 28px; font-size: 12.5px; color: #667085">다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다.</p>
</div>
</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>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M21 12a9 9 0 1 1-6.22-8.56"></path></svg>
<span style="flex: 1 1 auto; font-weight: 500">‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · <span style="font-variant-numeric: tabular-nums">4,200 / 8,412통 확인</span></span>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; 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">‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.</span>
<a href="#" class="on-dark toast-action" style="display: inline-flex; align-items: center; min-height: 36px; padding: 0 10px; border-radius: 7px; font-weight: 600">거래처 보기</a>
</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">‘결재 알림’ 필터를 받은편지함의 메일 12통에 적용했습니다.</span>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; 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">받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.</span>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">숫자는 확인한 메일 / 받은편지함의 메일 수다. 받은편지함의 최근 메일부터 본다. 옮기는 필터의 결과에는 그 메일함으로 가는 단추를 둔다 — 읽음 · 중요만 하는 필터는 단추가 없다.</p>
</section>
</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>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; 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="#FDA29B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span style="flex: 1 1 auto; font-weight: 500">‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요.</span>
<button type="button" class="on-dark toast-action" style="flex: 0 0 auto; min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; 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 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="#FDA29B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span style="flex: 1 1 auto; font-weight: 500">필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요.</span>
<button type="button" class="on-dark toast-action" style="flex: 0 0 auto; min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; 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="#FDA29B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span style="flex: 1 1 auto; font-weight: 500">다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.</span>
</div>
<p style="margin: 0; font-size: 12.5px; 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">고치기 창의 [취소] · Esc · 다른 화면으로 가기 — 창을 연 뒤 바꾼 것이 있을 때만 묻는다</p>
</div>
<div style="display: flex; justify-content: center; padding: 22px 16px; border-radius: 10px; background: rgba(16, 24, 28, 0.08)">
<div role="alertdialog" aria-modal="true" aria-labelledby="st-leave-title" aria-describedby="st-leave-message" style="width: 100%; max-width: 360px; box-sizing: border-box; padding: 20px 20px 16px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 8px">
<h3 id="st-leave-title" style="margin: 0; font-size: 15px; font-weight: 700">저장하지 않은 필터가 있습니다</h3>
<p id="st-leave-message" style="margin: 0; color: #4A5363">창을 닫으면 고친 내용이 사라집니다.</p>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px">
<button type="button" class="primary" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; 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>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">처음 초점은 [계속 고치기] · Esc 도 같다(서명 · 부재중 설정과 같은 모양). 다른 화면으로 가려다 물으면 "이 화면을 나가면…" · [나가기]. 탭을 닫거나 새로 고치면 브라우저가 묻는다. 읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다.</p>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">부재중 자동 응답 — 메일 서버에서 규칙이 꺼졌을 때</h2>
<p style="margin: 0; font-size: 12px; color: #667085">자동 응답을 켜 두었는데 메일 서버의 규칙이 이 화면 밖에서 바뀐 드문 경우 — 머리 아래 줄 (필터 화면의 줄과 같은 뜻)</p>
</div>
<div style="display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; border-radius: 8px; background: #FFF7E6; color: #7A5200">
<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" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span style="flex: 1 1 auto">켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다.</span>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">자동 응답을 꺼 두었으면 보이지 않는다. 저장하면 필터도 함께 다시 켜진다(한 규칙에 들어 있다). 지금까지는 이 화면이 이 상태를 몰라 "켜짐"으로만 보였다.</p>
</section>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1280}}'>
class Component extends DCLogic {
renderVals() {
return {};
}
}
</script>
</body>
</html>
+56 -1
View File
@@ -372,6 +372,43 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 읽기 화면 ⋯ 메뉴에 「이런 메일로 필터 만들기」(원문 내려받기 아래) — 설정 › 필터의 새 필터 창이 보낸 사람의 **주소**로 채워져 열린다.
- 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다(목록이 바로 바뀐다). 저장 "필터를 저장했습니다.", 지우기 "‘거래처 메일’ 필터를 지웠습니다. [되돌리기]".
## 7단계 — 이미 받은 메일에 필터 적용 (2026-10-29 확정 — `MailFilterApply` · `MailFilterApplyStates`)
필터를 저장할 때 받은편지함에 이미 있는 메일에도 그 필터 하나를 적용한다. 6단계의 두 한계(고치기 창의 나가기 확인 없음 · 부재중 화면이 꺼진 규칙을 모름)도 함께 고친다.
규칙은 계약 2절 "이미 받은 메일에 적용".
### 고치기 창 — 「받은편지함에 이미 있는 메일에도 적용」
- 할 일 아래, 6단계의 안내 띠 자리에 회색 상자 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5). 상자 아래 한 줄 "스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다."
- 창을 열 때마다 꺼져 있다 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다(읽기 화면에서 만드는 새 필터도 같다). 목록의 행에는 단추를 더하지 않는다 — 있는 필터는 창을 열어 고르고 저장한다.
- 설명은 "저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다." — 전달이 있는 필터면 " 전달은 새로 오는 메일에만 합니다."를 더한다.
- 고를 수 없는 두 경우(칸을 끄고 이름을 흐리게, 설명이 까닭):
전달만 하는 필터 "전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다." ·
이 탭에서 다른 필터를 적용하는 중 "다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다."
- 목록 머리의 설명과 안내 상자의 "새로 오는 메일에만"을 바꾼다(`MailFilterApply` 보드의 글 — 이미 받은 메일 줄을 더하고, 전달 줄에 "새로 오는 메일만").
### 저장한 뒤 — 진행 · 결과 알림
- "필터를 저장했습니다." 대신 4단계의 진행 알림: "‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인"(확인한 수 / 받은편지함 메일 수 — 최근 메일부터).
끝나면 그 자리에서 "‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다." + 옮기는 필터면 [거래처 보기](그 메일함으로) —
읽음 · 중요만 하는 필터는 단추 없음, 맞는 메일이 없으면 "받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다."
- 멈추면 "‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요." + [다시 시도](남은 메일부터 이어서).
처음 요청부터 실패하면 "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도]. 다른 창 · PC 에서 적용 중이면 "다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요."
- 그동안 다른 화면으로 가서 메일을 읽고 써도 이어 한다. 탭을 닫으면 멈춘다(이어 하지 않는다). 세션이 바뀌면 멈추고 알림을 닫는다.
### 저장하지 않고 닫을 때 (6단계 한계를 고친다)
- 창을 연 뒤 바꾼 것이 있으면 [취소] · Esc · 다른 화면으로 가기에 확인 — 서명 · 부재중 설정의 나가기 확인과 같은 모양:
"저장하지 않은 필터가 있습니다" / "창을 닫으면 고친 내용이 사라집니다." [계속 고치기](채운 단추 · 처음 초점 · Esc) [닫기].
다른 화면으로 가려다 물으면 "이 화면을 나가면 고친 내용이 사라집니다." [나가기]. 탭 닫기 · 새로고침은 브라우저 확인창.
- 읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다(바꾼 것은 창을 연 때와 견준다).
### 부재중 자동 응답 화면 — 엔진에서 규칙이 꺼졌을 때 (6단계 한계를 고친다)
- 자동 응답을 켜 두었는데(저장한 값) 엔진에서 우리 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(필터 화면의 줄과 같은 모양):
"켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다."
- 단추는 없다 — 저장 단추는 늘 누를 수 있다. 꺼 두었으면 보이지 않는다. 저장하면 필터도 함께 다시 켜진다.
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -531,4 +568,22 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **그 밖**: [다시 켜기]가 성공해도 알림은 없다(줄이 사라진다). 목록 아래 줄은 보드 글 그대로 "… 행을 누르면 고칩니다."까지.
전달 칸은 `type=email` · 254자이고, 보기 글(받는 사람 칸 · 전달 칸)은 로그인한 직원 주소의 도메인으로 만든다.
저장하는 사이 화면을 떠나면 칸 안에 보일 거절을 알림으로 띄운다(부재중 화면과 같다). 목록에 `aria-busy` 를 걸지 않는다(칸마다 `aria-disabled`).
- **하지 않은 것**: 고치기 창에 쓰다 만 채로 떠날 때 묻기(나가기 확인) — 보드에 없다. 창을 연 채 브라우저 뒤로 가기를 하면 쓴 것을 잃는다(알려진 한계).
- **하지 않은 것**: 고치기 창에 쓰다 만 채로 떠날 때 묻기(나가기 확인) — 보드에 없다. 창을 연 채 브라우저 뒤로 가기를 하면 쓴 것을 잃는다(알려진 한계 — 7단계에서 고쳤다, 위 "7단계").
### 7단계 구현하며 정한 보완 (2026-10-29 — 캔버스에는 아직 없다)
- **「…에도 적용」 줄 높이**: 할 일 칸과 같은 44(보드는 40) — 고치기 창의 밀도 예외는 조건 칸 · 단추 40 뿐이라 새 예외를 만들지 않는다.
- **고를 수 없는 경우를 넓혔다**(보드는 전달만 · 적용 중 둘): 옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 하는 필터도 이미 받은 메일에 할 일이 없다(서버도 옮기지 않는다).
까닭은 이 차례로 하나만 — 이 탭에서 적용 중 → 지워진 메일함 → 받은편지함으로 옮기기만 → 전달만. 할 일을 아직 하나도 고르지 않았으면 막지 않는다(저장할 때 칸 아래 "할 일을 하나 이상 골라 주세요.").
고를 수 없게 되면 골라 둔 것을 쓰지 않고, 다시 고를 수 있게 되면 돌아온다. 같은 판정으로 저장한 뒤에도 시작하지 않는다 — 그때는 "필터를 저장했습니다."
- **꺼 둔 필터도 적용한다** — 사용자가 이번 저장에 고른 일이다. 적용 칸만 고르고 다른 칸을 고치지 않아도 바꾼 것으로 본다(나가기 확인 · [저장]).
- **전체 수**: 첫 응답 전에는 받은편지함의 메일 수(메일함을 아직 받지 않았으면 수를 뺀 문구), 그 뒤로는 "확인 + 남은 수"(최근 메일부터라 그사이 온 메일 · 옮겨진 메일로 조금 바뀔 수 있다).
- **실패 알림**: 요청은 인터셉터 알림을 끄고 보내고, 응답이 온 때 세션이 그대로일 때만 사전 문구로 알린다(세션 만료 대화상자는 그대로) — 그래서 처음 요청부터 실패하면
사전 문구 알림과 진행 알림의 결과 "필터는 저장했지만 …"가 함께 보인다(4단계 오래 걸리는 처리와 같다). 다른 창 · PC 에서 적용 중(429)은 결과 문구 하나만.
도중의 429 는 이어 할 자리가 있으니 [다시 시도]를 붙인다(처음 요청의 429 는 보드처럼 단추 없음).
- **이 탭에서 적용 중에 [다시 시도]**: 시작하지 않고 "다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 자리 · 같은 수의 [다시 시도].
- **나아가지 못한 응답**(확인 0 인데 이어 할 자리가 있다)이 세 번 이어지면 멈춘다(`MAIL_FILTER_RUN_MAX_STALLS`) — 같은 요청을 끝없이 보내지 않게.
- **맞추기**: 끝났거나 이번에 나아간 뒤 멈췄으면 개수 · 사용량을 다시 받고, 보이는 목록이 받은편지함이나 옮긴 메일함이면 그 목록도 맞춘다(실시간 연결이 없는 탭에서 1분까지 옛 개수가 보이지 않게).
- **닫기 확인과 고치기 창**: 브라우저가 Esc 를 거듭 누르면 고치기 창을 막지 못하고 스스로 닫는다 — [계속 고치기]면 창을 다시 띄우고 초점은 닫히기 전에 있던 칸으로 돌려준다.
닫기 확인이 떠 있는 동안 다른 화면으로 가려 하면(뒤로 가기) 같은 확인 창이 "이 화면을 나가면 …" [나가기]로 바뀐다. 저장 · 지우기를 보내는 중에는 묻지 않는다(결과는 알림으로).
- **부재중**: 메일함 패널 아래의 부재중 안내는 그대로 둔다(저장한 값으로 보인다 — 동작하지 않음은 설정 화면에서만 알린다).
+6 -3
View File
@@ -24,16 +24,19 @@
"SettingsNotify.dc.html": { "x": 0, "y": 8560, "w": 1440, "h": 900, "title": "설정 · 알림", "expand": "fill", "is_interactive": true },
"MailRealtime.dc.html": { "x": 1520, "y": 8560, "w": 1440, "h": 1520, "title": "실시간 새 메일 · 바탕화면 알림 상태" },
"SettingsFilter.dc.html": { "x": 0, "y": 10680, "w": 1440, "h": 900, "title": "설정 · 필터", "expand": "fill", "is_interactive": true },
"MailFilterStates.dc.html": { "x": 1520, "y": 10680, "w": 1440, "h": 1420, "title": "메일 필터 상태 · 메일에서 바로 만들기" }
"MailFilterStates.dc.html": { "x": 1520, "y": 10680, "w": 1440, "h": 1420, "title": "메일 필터 상태 · 메일에서 바로 만들기" },
"MailFilterApply.dc.html": { "x": 0, "y": 12700, "w": 1440, "h": 900, "title": "설정 · 필터 · 이미 받은 메일에 적용", "expand": "fill", "is_interactive": true },
"MailFilterApplyStates.dc.html": { "x": 1520, "y": 12700, "w": 1440, "h": 1280, "title": "필터 적용 진행 · 결과 · 나가기 확인 · 부재중 안내" }
},
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html"],
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html"],
"notes": {
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
"phase3": { "x": 0, "y": 4180, "text": "3단계 화면 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정)", "kind": "title1", "maxW": 4480 },
"phase4": { "x": 0, "y": 6340, "text": "4단계 화면 — 계정 · 메일 설정 (비밀번호 · 부재중 응답 · 메일함 관리 · 스팸 · 원문) — 2026-10-28 확정", "kind": "title1", "maxW": 6000 },
"phase5": { "x": 0, "y": 8260, "text": "5단계 화면 — 실시간 새 메일 · 바탕화면 알림 — 2026-10-28 확정", "kind": "title1", "maxW": 2960 },
"phase6": { "x": 0, "y": 10380, "text": "6단계 화면 — 메일 필터 (설정 › 메일 › 필터 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」) — 2026-10-28 확정", "kind": "title1", "maxW": 2960 }
"phase6": { "x": 0, "y": 10380, "text": "6단계 화면 — 메일 필터 (설정 › 메일 › 필터 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」) — 2026-10-28 확정", "kind": "title1", "maxW": 2960 },
"phase7": { "x": 0, "y": 12400, "text": "7단계 화면 — 이미 받은 메일에 필터 적용 · 필터 창 나가기 확인 · 부재중 안내 — 2026-10-29 확정", "kind": "title1", "maxW": 2960 }
},
"designSystems": []
}