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": []
}
+95 -7
View File
@@ -208,10 +208,11 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacation` (아래 "부재중 자동 응답") | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체) | 200 `MailVacation`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` |
| `GET /api/mail-filters` | — | 200 `MailFilterSet` (아래 "메일 필터", 6단계) | 503 `MAIL_001` |
| `PUT /api/mail-filters` | `{ rules, version }` (전체 교체) | 200 `MailFilterSet` | 400 `VAL_001` · `MAIL_013`(만든 규칙이 엔진 한도를 넘음), 409 `MAIL_012`(그사이 다른 곳에서 필터가 바뀜 — 저장 안 함), 503 `MAIL_001` |
| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` |
| `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` |
| `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` |
| `GET /api/mail-events` | — | `text/event-stream` (표준 래퍼 없음 — 아래 "실시간 알림", 5단계) | 401 `AUTH_001`, 429 `REQ_001`(한 직원의 연결 5개 초과), 503 `MAIL_001` |
@@ -408,6 +409,10 @@ interface RejectedRecipient {
- 메일 한 통의 첨부 합계(전달하는 원본 첨부 포함, 같은 첨부를 두 번 붙이면 두 번 센다) `attachmentTotalMaxBytes`
(`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`, 기본 25MB) — 넘으면 발송이 413 `FILE_002`.
클라이언트는 첨부를 추가할 때 이 값으로 **미리 막고** 안내한다 (다 쓰고 보낼 때 알게 하지 않는다).
- **엔진의 임시 업로드 한도**(7단계에 찾음): 계정마다 1시간 동안 올린 크기 합계 · 개수(Stalwart `uploadQuota` · `maxUploadCount` · `uploadTtl`). 엔진 기본값은 50MB · 1,000개 —
넘으면 올린 것이 만료될 때까지(1시간) 그 직원의 첨부 올리기와 필터 · 부재중 저장(엔진에 규칙 글을 올린다) · 서명 그림이 든 발송이 429 `REQ_001` 로 막힌다
(2026-10-29 개발 엔진: 1,000개를 넘기자 필터 저장이 막혔고, 20MiB 두 개 뒤 세 번째 업로드가 429 — 크기 · 개수 어느 쪽이든 같은 `REQ_001`, 백엔드 로그 `"call":"upload"`).
그래서 mail setup 이 크기 합계를 첨부 합계 한도의 20배(기본 25MB × 20 = 524,288,000바이트)로 둔다 — 개수(1,000)는 그대로(mail/README.md "엔진 한도").
- 허용 형식(확장자 + MIME 둘 다 검사): pdf · doc/docx · xls/xlsx · ppt/pptx · hwp/hwpx · txt · csv · png · jpg/jpeg · gif · webp · zip
- 다운로드는 `<a href>` 이동으로 받는다 — 같은 출처라 쿠키가 함께 간다.
이동은 axios 를 거치지 않아 401 재시도 규칙이 적용되지 않으므로, **이동하기 전에 세션을 확인**한다
@@ -681,6 +686,10 @@ interface MailVacation {
subject: string // 켤 때 150자까지(꺼 둔 채로는 200자까지 — 아래 "길이 한도의 근거") — 켤 때(isEnabled true)는 공백이 아닌 글자 1자 이상. 제어 문자(메일함 이름과 같은 범위 — 탭 · 줄바꿈 포함)는 쓰지 않는다(한 줄짜리 머리글)
textBody: string // 켤 때 600자까지(꺼 둔 채로는 4,000자까지) — 켤 때는 공백이 아닌 글자 1자 이상. 글자만(서식 없음) — 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
}
interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7단계)
isActive: boolean // false = 엔진에서 우리 규칙(필터와 한 스크립트)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다.
// 메일 필터의 isActive 와 같은 값(한 번도 저장하지 않은 계정은 true). PUT 본문에는 넣지 않는다(넣으면 400 VAL_001 — 모르는 필드)
}
```
- **길이 한도의 근거 (6단계 앞 수정)**: 엔진이 UTF-8 **바이트**로 막는다 — 제목 511바이트(한글 170자 · 영문 511자), 본문 2,047바이트(한글 682자 · 영문 2,047자)
@@ -696,8 +705,9 @@ interface MailVacation {
서식 본문은 없다(글자만). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다.
- 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve").
- 우리 스크립트가 엔진에서 꺼져 있으면(필터의 `isActive: false`) 부재중도 동작하지 않는다. 이 API 의 `isEnabled` 는 저장한 값이라 그때도 `true` 로 온다
(알려진 한계 — 드묾. 화면은 필터 화면의 줄로만 알린다).
- 우리 스크립트가 엔진에서 꺼져 있으면(`isActive: false`) 부재중도 동작하지 않는다 — `isEnabled` 는 저장한 값이라 그때도 `true` 다.
7단계부터 응답의 `isActive` 로 알린다 — 화면은 켜 두었을 때 머리 아래 줄 "켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 —
아래 [저장]을 누르면 다시 켜집니다."(단추 없음 — 저장 단추는 늘 누를 수 있다. 6단계의 알려진 한계를 고쳤다).
- 저장해 둔 원본이 깨진 계정은 꺼짐(`isEnabled: false`, 빈 제목 · 내용)으로 온다. 원본이 이 서버보다 새 판이면 저장은 503 `MAIL_001` 이다("메일 필터"의 "저장 자리").
- `fromDate` · `toDate` 는 `Z` 로 끝나는 UTC 시각이고, 두 키는 늘 보낸다(값은 null 가능). 엔진에는 초 단위로 저장한다(소수 초는 버린다).
둘 다 있으면 버린 값으로 `fromDate < toDate`(아니면 400 `VAL_001`). 화면은 날짜만 고르고, 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다.
@@ -832,7 +842,7 @@ interface MailFilterActions {
전달은 한 통에 한 곳 — 엔진 한도(사용자 스크립트의 리다이렉트 1)라 두 번째 전달은 엔진이 조용히 버린다(2026-10-28 확인). 사본은 늘 남긴다(`redirect :copy`).
**스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다**(부재중 응답도 하지 않는다) — 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로(같은 날 확인) 막는다.
판정은 엔진의 분류 그대로다(지금 엔진 설정은 5점부터 스팸 — 아래 "만드는 Sieve"의 스팸 막기).
꺼 둔 규칙은 목록에만 있고 적용하지 않는다. 이미 받은 메일에는 적용하지 않는다.
꺼 둔 규칙은 목록에만 있고 적용하지 않는다. 이미 받은 메일에는 저절로 적용하지 않는다 — 저장할 때 고르면 아래 "이미 받은 메일에 적용"(7단계).
- **저장 자리** — 엔진의 Sieve 스크립트 하나(`arasystem`)에 필터와 **부재중 자동 응답을 함께** 둔다. Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고
엔진의 VacationResponse 도 스크립트(`vacation`)라서, 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다(같은 날 확인).
- 4단계 `/api/mail-vacation` 은 계약 그대로 두고 저장 자리만 이 스크립트로 옮긴다 — 엔진의 VacationResponse 는 더 쓰지 않는다.
@@ -878,8 +888,86 @@ interface MailFilterActions {
- 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 · `${` · 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다.
전달 주소의 앞부분은 서버(`IsEmail`)보다 좁게 본다(RFC 5322 dot-atom · 64자) — 화면이 통과시킨 주소를 서버가 거절하지 않게.
규칙이 50개면 [새 필터] · 「이런 메일로 필터 만들기」를 끄고 까닭을 보인다. 지운 메일함을 가리키는 규칙은 목록에 빨간 안내.
- 이 단계에서 하지 않는 것: 이미 받은 메일에 적용, **바깥 주소로 전달**(엔진에 SRS 가 없어 Gmail 등이 DMARC 로 거절할 수 있고, 회사 메일이 개인 메일로 빠지는 통로다 —
필요해지면 정책과 함께), 크기 · 첨부 · 날짜 조건, 정규식, 맞는 메일 미리 보기, mailcow(SOGo)의 필터 옮겨 오기(운영 전환 때 — imapsync 로는 옮겨지지 않는다).
- 이 단계에서 하지 않는 것: **바깥 주소로 전달** — 전달한 메일은 보낸 사람(From)이 원래의 바깥 도메인인데 우리 서버가 건넨다. 엔진은 봉투 보낸 사람을
필터 주인으로 바꿔 SPF 는 통과하지만(SRS 는 없다 — 거절되면 반송은 원래 보낸 사람이 아니라 필터 주인에게 온다), 받는 쪽의 DMARC 는 From 기준이라
원래 보낸 쪽의 DKIM 서명에 기댄다. 서명이 없거나 깨진 메일은 그 도메인이 엄격하면(p=reject) Gmail 등이 거절하거나 스팸으로 보낸다 — 전달 메일의 일반적인 한계라
SRS 가 있어도 같다. 그리고 회사 메일이 개인 메일로 자동으로 빠지는 통로다 — **회사 정책으로 허용하지 않는다**(2026-10-29 결정 — 전달은 사내 주소로만).
보통 발송(직원이 바깥 주소로 보내는 메일)과는 상관없다.
크기 · 첨부 · 날짜 조건, 정규식, 맞는 메일 미리 보기, mailcow(SOGo)의 필터 옮겨 오기(운영 전환 때 — imapsync 로는 옮겨지지 않는다).
### 이미 받은 메일에 적용 (`/api/mail-filter-runs`) — 7단계
필터를 저장할 때 고르면(고치기 창 「받은편지함에 이미 있는 메일에도 적용」) 받은편지함에 이미 있는 메일에도 그 규칙 하나를 적용한다.
엔진의 Sieve 는 메일을 받을 때만 돌기 때문에 서버가 받은편지함을 읽어 같은 규칙으로 판정하고 표시 · 옮기기를 한다.
```ts
interface MailFilterRunRequest {
match: 'all' | 'any' | 'always' // 필터 규칙과 같은 검사(MailFilterRule 의 match · conditions)
conditions: MailFilterCondition[] // all · any 는 1~5개, always 는 빈 배열
actions: { // 하나 이상 — 전달은 없다(이미 받은 메일은 전달하지 않는다)
moveToMailboxId: string | null // 필터 규칙과 같은 메일함만(보낸편지함 · 임시보관함은 400). 받은편지함이거나 없는 메일함이면 옮기지 않는다
markAsRead: boolean
flag: boolean
}
cursor: string | null // 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로(해석하지 않는다)
}
interface MailFilterRun {
scanned: number // 이번 요청에서 확인한 받은편지함 메일 수
matched: number // 그중 조건에 맞아 할 일을 한 메일 수(이미 읽음 · 중요였던 메일도 센다)
remaining: number // 아직 확인하지 않은 메일 수 — 어림(그사이 들어오고 나간 메일이 있다)
cursor: string | null // null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다
}
```
- 받은편지함만, 받은 시각이 최근인 메일부터 본다. 저장해 둔 필터를 읽지 않고 본문의 규칙을 쓴다(클라이언트는 방금 저장한 그 규칙을 보낸다) —
그래서 적용하는 동안 다른 필터를 켜고 끄거나 저장해도 멈추지 않는다.
- 한 요청은 25초 안에 끝내고 `cursor` 를 돌려준다 — 4단계 "오래 걸리는 처리"와 같은 상한 · 같은 규칙(다음 차례는 남은 시간이 5초보다 많을 때만, 엔진 호출마다 남은 시간만큼,
진행한 것이 있으면 거기까지 200, 하나도 확인하지 못했으면 503 `MAIL_001`). 같은 직원의 적용은 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(다른 탭 · PC).
- **판정은 서버가 한다** — 새로 오는 메일의 Sieve(위 "메일 필터"의 "만드는 Sieve")와 같은 규칙을 따라 한다. 엔진 검색(`Email/query` 의 글자 조건)은 쓰지 않는다 —
낱말이 통째로 같을 때만 맞고('회의'로 '회의를' · '아라'로 '김아라'를 못 찾는다) `header` 조건은 아무것도 찾지 못한다(Stalwart 0.16.27, 2026-10-28 확인).
- 보낸 사람: From 머리글의 원래 글 — 접힌 줄(줄바꿈 + 공백 · 탭)은 띄어쓰기 하나로 펴고(접지 않은 탭 · 이어진 공백은 그대로), 인코딩된 글(RFC 2047 — 문자셋 그대로)은
**홀로 선 것만** 풀고(앞이 처음 · 공백 · `"` · `,`, 뒤가 끝 · 공백 · `"` — `abc=?…?=` · `=?…?=<a@b>` · `(=?…?=)` 는 그대로. 공백만 사이에 둔 인코딩된 글끼리는 붙인다),
앞뒤 공백을 뺀 것 — 따옴표 · 꺾쇠까지 견준다(`"Kim, Ara" <kim@…>` 에 `"Kim` 이 맞는다 — 엔진과 같다). 받는 사람: To · Cc 머리글 각각(하나라도 맞으면). 제목: 풀어 쓴 제목.
- 본문: 텍스트 · HTML 부분과 **텍스트 첨부**(text/* — 파일로 붙인 것도. 엔진이 그렇게 본다). 한 부분은 앞 256KiB 까지, 한 통은 모두 합쳐 1MiB 까지 본다
(글자 부분이 많아 넘으면 부분마다 같은 몫만큼 앞쪽만 — 바깥에서 온 메일 한 통으로 서버가 메모리를 크게 쓰지 않게). HTML 은 글자 부분만 — 엔진과 같게:
- 태그는 `<` 부터 다음 `>` 까지다 — `<` 뒤가 무엇이든 태그이고(`a < b` · `<광고>` 도), 따옴표 안의 `>` 에서도 끝난다. 닫히지 않은 `<` 는 끝까지 삼킨다.
태그는 속성까지 **빈자리 없이** 뺀다(`회<b>의</b>록` → "회의록", `div` · `li` · `h1` · 표 칸 · 표 줄 · `hr` · 여는 `p` 사이도 이어 붙는다). 주석(`<!-- … -->`)은 안의 글까지 뺀다
- `</p>`(닫는 것만) · `<br>`(`<br/>` · `<br />` · `</br>` · 대문자도)은 줄바꿈이다 — 찾을 글(줄바꿈이 없다)은 이 자리를 건너 맞지 않는다(`<p>앞</p><p>뒤</p>` 에 "앞뒤" · "앞 뒤" 모두 안 맞음)
- `<style>` · `<script>` · `<head>` · `<template>` 은 안의 글까지 뺀다(`<title>` · `<noscript>` 등은 글이 남는다). 그 안의 `</p>` · `<br>` 도 줄바꿈이다
- **태그 이름 뒤에 속성이 붙으면 엔진은 그 태그를 알아보지 못한다** — `<br clear="all">` 은 줄바꿈이 아니고 `<style type="text/css">` · `<head lang="ko">` 는 안의 글이 남는다. 서버도 그렇게 본다
- 원문의 이어진 공백(띄어쓰기 · 탭 · 줄바꿈)은 태그를 건너서도 띄어쓰기 하나. 문자 참조를 푼다(이름 · 10진 · 16진 — `;` 로 끝나는 것만, `&nbsp;` 는 U+00A0) —
푼 글자는 줄이지 않고 그대로 넣는다(`&#32;&#32;` 는 띄어쓰기 둘, `&#10;` 은 줄바꿈)
- 대소문자는 ASCII 만 무시한다. "포함하지 않음"은 그 머리글 · 본문 부분이 없을 때도 참이다(엔진과 같다).
- 확인(2026-10-29): 실배달 20통 — 본문 14가지(HTML 만 · 속성 · style · 문자 참조 · 대안 두 쪽 · 텍스트 첨부 · 주석 · script · 인라인 부분 · QP · EUC-KR · `&nbsp;` · 블록 사이)와
머리글 6가지(따옴표 이름 · 받는 사람 여럿 · 인코딩된 참조 · 인코딩된 제목 · EUC-KR 제목), 인증 발송 25통 — HTML 글자 뽑기(인라인 태그 · 원문 줄바꿈 · 공백 겹침 ·
p · br · div · li · h1 · 표 · blockquote · hr · title · 숫자 참조 · 대문자 태그 등), 인증 발송 69통 — 속성이 붙은 태그 · 글을 빼는 태그(head · template 등) ·
글자 그대로의 `<` · 따옴표 안 `>` · 참조로 쓴 공백 · 머리글의 접힌 줄 · 인코딩된 글의 경계에서 엔진과 같은 판정이 되게 맞췄다(스팸으로 분류된 1통은 판정 불가).
- **알려진 한계**(드묾): 첨부한 메일(.eml) 안, 256KiB · 1MiB 를 넘는 뒤쪽, HTML5 에만 있는 이름 참조(`&period;` · 대문자 `&AMP;` 등 — 엔진은 푼다)는 엔진과 다를 수 있다.
- 스팸 판정 표시는 보지 않는다 — 받은편지함에 있는 메일은 사용자가 이미 본 메일이다("스팸 아님"으로 되돌린 메일 포함).
- 할 일: 읽음 · 중요 표시를 붙이고, 옮기기는 받은편지함에서 빼고 그 메일함에 넣는다(맞은 메일을 차례마다 엔진 요청 한 번으로). 확인하는 사이 받은편지함에서 빠진 메일은 건너뛴다.
옮길 메일함이 없거나(지워짐) 받은편지함이면 옮기지 않고 표시만 한다. 영구 삭제는 하지 않는다(휴지통으로 옮기는 규칙도 옮기기다).
- 적용을 시작한 때 받은편지함에 있던 메일만 본다 — 첫 요청이 받은편지함의 가장 최근 받은 시각을 위 경계로 잡고(cursor 에 담는다) 그 뒤에 온 메일은 보지 않는다
(새로 온 메일은 Sieve 가 규칙 차례대로 이미 판정했다 — 한 규칙으로 다시 판정하면 위 규칙의 "멈춤"을 건너뛴다).
- `cursor` 는 서버가 만든 글이다(위 경계 · 마지막으로 받은편지함에 남긴 메일과 그 받은 시각 · 규칙의 지문). 그 메일이 그사이 빠졌으면(다른 탭에서 옮김 등)
그 받은 시각의 메일부터 다시 읽는다 — 건너뛰지 않는다(그 초의 메일을 다시 판정해 맞은 수를 두 번 셀 수는 있다). 엔진은 받은 시각이 같은 메일을 늘 같은 차례로 준다(만든 차례 — 2026-10-29 확인).
다른 규칙의 cursor · 깨진 cursor 는 400 `VAL_001`.
- 엔진 한도: `Email/query` 는 한 번에 5,000통까지(`queryMaxResults`)라 차례마다 나눠 읽는다. 측정(개발 엔진, 2026-10-29, 시험 메일 5,000통): 보낸 사람 조건으로 4,500통을 확인하고
450통을 옮기는 데 1.3초, 본문 2,000통 읽기 1초 안팎 — 받은편지함 수만 통도 머리글 조건이면 한 요청, 본문 조건이면 몇 요청이다.
- 로그 — 규칙의 글은 남기지 않는다(필터와 같다). 확인 · 적용 수 · 차례 수 · 걸린 시간만.
- **클라이언트**: 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 — 창을 열 때마다 꺼져 있다(필터의 설정이 아니다). `PUT /api/mail-filters` 가 성공한 뒤 저장한 그 규칙
(응답 목록의 같은 id — 앞뒤 공백을 지운 값)으로 이 요청을 `cursor` 가 null 이 될 때까지 보낸다.
- 칸을 끈다(칸 아래 설명이 까닭을 말한다): 이 탭에서 다른 적용이 진행 중, 그리고 **이미 받은 메일에 할 일이 없는 필터** — 읽음 · 중요 표시가 없고
옮기기도 없거나 받은편지함으로 · 지워진 메일함으로 옮기는 것뿐(까닭 문구는 지워진 메일함 → 받은편지함 → 전달만 차례로 고른다). 서버는 그런 규칙도 받는다(맞은 수만 센다).
- 진행 알림 "‘{이름}’ 필터를 받은편지함에 적용하는 중 · {확인} / {전체}통 확인"(확인 = scanned 합, 전체 = 확인 + remaining) → 끝나면 그 자리에서
"‘{이름}’ 필터를 받은편지함의 메일 N통에 적용했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에 ‘{이름}’ 필터에 맞는 메일이 없습니다."
- 멈춤: "‘{이름}’ 필터 — {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다(N통에 적용). 다시 시도해 주세요." + [다시 시도](마지막 cursor 로 이어서).
처음 요청부터 실패하면 "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터).
429 는 "다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요." — 도중의 429 면 [다시 시도](이어서)도 붙인다.
확인 0 인데 `cursor` 가 있는 응답이 세 번 이어지면 멈춘다(같은 요청을 끝없이 보내지 않게 — 멈춤 문구와 같다).
- 그동안 다른 화면으로 가도 이어 한다. 탭을 닫으면 멈춘다(이어 하지 않는다). 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 다음 요청을 보내지 않고 알림을 닫는다.
- **고치기 창의 나가기 확인**(6단계 한계를 고친다): 창을 연 뒤 바꾼 것이 있으면 [취소] · Esc · 다른 화면으로 가기에 확인 — "저장하지 않은 필터가 있습니다" /
"창을 닫으면 고친 내용이 사라집니다." [계속 고치기](처음 초점 · Esc) [닫기]. 다른 화면으로 갈 때는 "이 화면을 나가면 고친 내용이 사라집니다." [나가기].
탭 닫기 · 새로고침은 브라우저 확인창. 읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다.
### 이 단계에서 하지 않는 것