docs: 6단계(메일 필터) 계약과 디자인 · JSON 본문 모양 한도

메일 필터(GET·PUT /api/mail-filters)와, 부재중 자동 응답의 저장 자리를 엔진의 Sieve
스크립트 하나로 옮기는 계약을 적는다. 확정한 캔버스 보드(SettingsFilter · MailFilterStates)와
구현하며 정한 보완도 함께 둔다.

- 엔진(Stalwart 0.16.27)은 계정마다 켜진 스크립트가 하나뿐이다. 엔진의 부재중도 스크립트라
  따로 두면 하나를 켤 때 다른 하나가 꺼진다. 그래서 한 스크립트에 함께 둔다
- 스팸 막기는 엔진의 분류 표시(X-Spam-Score)로 한다. spamtest 는 점수를 정수로 내린 값이라
  10 으로 막으면 5~10점 스팸을 놓친다(실엔진에서 확인)
- 모든 JSON 본문에 모양 한도(배열 1,000 · 값 10,000 · 깊이 32)를 걸고 UTF-8 만 받는다.
  DTO 의 개수 한도로는 본문 검증 DoS 를 막지 못했다(리뷰 측정)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 20:58:51 +09:00
parent b6aa73c611
commit 41b5fdbe22
5 changed files with 839 additions and 8 deletions
@@ -0,0 +1,219 @@
<!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:disabled{background:#E4E7EC;color:#98A2B3;cursor:not-allowed}
.ghost{background:transparent;color:#4A5363}
.bar-action{background:transparent;color:#0A5F67}
.bar-action:hover{background:#E3F2F3}
.warn-action{background:transparent;color:#7A5200}
.warn-action:hover{background:#FDEBC8}
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
.field.is-error{border-color:#B42318}
.menu-item{background:transparent;color:#171C26}
.menu-item:hover{background:#F2F4F7}
.toast-action{background:transparent;color:#9ED6DB}
</style>
</helmet>
<div style="width: 1440px; min-height: 1420px; 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: 960px">
<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">6단계에서 새로 생기는 것입니다. 필터는 설정 › 메일 › 필터에서 만들고, 「설정 · 필터」 보드에서 눌러 볼 수 있습니다. 이 보드는 그 화면의 다른 상태와 메일 읽기 화면에서 바로 만드는 길을 모았습니다.</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; align-items: center; gap: 8px; padding: 40px 24px; border: 1px dashed #D0D5DD; border-radius: 10px; text-align: center">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
<p style="margin: 4px 0 0; font-size: 14px; font-weight: 600">아직 필터가 없습니다</p>
<p style="margin: 0; max-width: 400px; font-size: 12.5px; color: #667085">보낸 사람 · 받는 사람 · 제목 · 본문의 글로 메일을 골라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다.</p>
<button type="button" class="primary" style="margin-top: 6px; 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>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">저장하지 못하는 필터 — 칸 아래에 알린다</h2>
<p style="margin: 0; font-size: 12px; color: #667085">「저장」을 누를 때 검사한다 · 알림(토스트)은 띄우지 않는다 · 첫 잘못된 칸에 초점</p>
</div>
<div style="display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid #E6E8EC; border-radius: 10px">
<div style="display: grid; grid-template-columns: 104px 120px minmax(0, 1fr); gap: 8px">
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">보낸 사람</span>
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">포함</span>
<span class="field is-error" style="display: flex; align-items: center; height: 40px; padding: 0 10px; color: #98A2B3">예: @hanbit-partners.co.kr 또는 이름</span>
</div>
<p style="margin: -4px 0 0; font-size: 12.5px; color: #B42318">비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.</p>
<div style="display: grid; grid-template-columns: 104px 120px minmax(0, 1fr); gap: 8px">
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">제목</span>
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">포함</span>
<span class="field is-error" style="display: flex; align-items: center; height: 40px; padding: 0 10px">가격 ${price}</span>
</div>
<p style="margin: -4px 0 0; font-size: 12.5px; color: #B42318">‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.</p>
<div style="display: flex; align-items: center; gap: 12px">
<span style="min-width: 150px">동료에게 전달</span>
<span class="field is-error" style="flex: 1 1 auto; display: flex; align-items: center; height: 40px; padding: 0 10px">me@gmail.com</span>
</div>
<p style="margin: -4px 0 0; font-size: 12.5px; color: #B42318">사내 주소(@arasystem.it.kr)로만 전달할 수 있습니다.</p>
<p style="margin: 0; font-size: 12.5px; color: #B42318">할 일을 하나 이상 골라 주세요. <span style="color: #667085">— 할 일 칸이 모두 비었을 때</span></p>
</div>
</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">다른 탭 · PC 에서 필터를 먼저 저장한 뒤 이 화면에서 저장하면 — 목록 위 줄 (부재중 응답을 저장한 것은 걸리지 않는다)</p>
</div>
<div style="display: flex; align-items: flex-start; gap: 10px; padding: 10px 8px 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"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
<span style="flex: 1 1 auto">다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.</span>
<button type="button" class="warn-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>
<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">메일 서버의 규칙이 이 화면 밖에서 바뀐 드문 경우(관리자 작업 등) — 목록 위 줄</p>
</div>
<div style="display: flex; align-items: flex-start; gap: 10px; padding: 10px 8px 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>
<button type="button" class="warn-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>
<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">50개를 다 만들었을 때</h2>
<p style="margin: 0; font-size: 12px; color: #667085">[새 필터]를 끄고 까닭을 옆에 · 메일 읽기 화면의 「이런 메일로 필터 만들기」도 같은 안내</p>
</div>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px">
<button type="button" class="primary" disabled 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">
<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>
<span style="font-size: 12.5px; color: #667085">필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.</span>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">조건이 길어 메일 서버의 한도(100KB)를 넘으면 저장할 때 알린다 — "필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요."</p>
</section>
</div>
<div style="display: grid; grid-template-columns: minmax(0, 1.15fr) 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: grid; grid-template-columns: 236px minmax(0, 1fr); gap: 18px; align-items: start">
<div style="position: relative; display: flex; flex-direction: column; gap: 8px">
<div style="display: flex; align-items: center; justify-content: flex-end; gap: 4px; padding: 4px; border: 1px solid #E6E8EC; border-radius: 8px">
<span style="flex: 1 1 auto; padding-left: 6px; font-size: 12px; color: #667085">읽기 화면 도구 줄</span>
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 7px; background: #F2F4F7; color: #171C26">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg>
</span>
</div>
<div role="menu" aria-label="메일 더 보기" style="display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.14)">
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
<span>원문 내려받기</span>
</button>
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: #F2F4F7; font: inherit; font-weight: 600; text-align: left; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
<span>이런 메일로 필터 만들기</span>
</button>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid #E6E8EC; border-radius: 10px">
<span style="font-size: 15px; font-weight: 700">새 필터</span>
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">어떤 메일에</span>
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 10px">다음 조건이 모두 맞는 메일</span>
<div style="display: grid; grid-template-columns: 96px 76px minmax(0, 1fr); gap: 8px">
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">보낸 사람</span>
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">포함</span>
<span class="field" style="display: flex; align-items: center; min-width: 0; height: 40px; padding: 0 10px; overflow: hidden; white-space: nowrap">seojun.park@arasystem.it.kr</span>
</div>
<p style="margin: 0; 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 style="display: flex; flex-direction: column; gap: 10px">
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">필터를 저장했습니다.</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">
<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>
<button type="button" class="on-dark toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
</div>
<div role="alert" style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px 14px; border: 1px solid #E6E8EC; border-left: 3px solid #D92D20; border-radius: 10px; background: #FFFFFF; color: #171C26; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.10)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#D92D20" stroke-width="2" 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 style="color: #667085">— 켜기 · 순서 바꾸기가 실패하면 목록을 되돌리고</span></span>
</div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">[되돌리기]는 지우기 직전의 목록 그대로 다시 저장한다(10초 · 마우스를 올린 동안 멈춤 — 지금의 되돌리기 알림과 같다).</p>
</section>
</div>
<section style="display: flex; flex-direction: column; gap: 12px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">어떻게 적용하나</h2>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 24px">
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">1</span><span>새로 온 메일을 <strong style="font-weight: 600">위의 필터부터</strong> 차례로 견준다. 맞는 필터의 할 일을 모두 한다 — 읽음 · 중요 표시는 겹쳐 붙는다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">2</span><span><strong style="font-weight: 600">메일함으로 옮기는 필터에 맞으면 거기서 멈춘다</strong> — 아래 필터는 보지 않는다. 받은편지함으로 옮기기를 위에 두면 "이 사람 메일은 늘 받은편지함에"가 된다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">3</span><span>전달은 한 통에 한 곳 — 먼저 맞은 필터의 주소로만 간다. 사본은 늘 내 메일함에 남는다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">4</span><span>스팸으로 판정된 메일은 필터를 건너뛰고 스팸메일함으로 간다. 부재중 자동 응답도 스팸에는 답하지 않는다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">5</span><span>옮길 메일함이 지워졌으면 받은편지함으로 들어온다 — 필터 목록에 빨간 안내. 메일함 이름을 바꾼 것은 그대로 이어진다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">6</span><span>꺼 둔 필터는 목록에만 남고 적용하지 않는다. 이미 받은 메일에는 적용하지 않는다.</span></p>
</div>
</section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1420}}'>
class Component extends DCLogic {
state = {};
renderVals() {
return {};
}
}
</script>
</body>
</html>
+61 -2
View File
@@ -1,8 +1,8 @@
# 웹메일 디자인 원본 (1~5단계)
# 웹메일 디자인 원본 (1~6단계)
확정된 디자인 캔버스의 원본이다. 화면 구현의 기준으로 쓴다.
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-28, 버전 27)
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-28, 버전 31)
- 화면: `Login`(로그인) · `Main`(분할 보기: 메일함·목록·본문) · `MainList`(목록 보기: 게시판형 표) · `MainListRead`(목록 보기에서 연 메일)
· `Compose`(메일 쓰기 — 서식 편집기) · `ComposeEditor`(메일 쓰기 · 편집기 상태: 포커스·툴바·메뉴·팝오버·인용·이미지 첨부)
· `States`(오류·빈 상태·피드백 모음)
@@ -10,6 +10,7 @@
· 3단계: `Search`(메일 검색 · 분할 보기) · `BulkSelect`(여러 통 선택 · 목록 보기) · `ListStates`(새 메일 · 검색 · 여러 통 처리 상태) — 아래 "3단계"
· 4단계: `SettingsAccount`(설정 · 비밀번호) · `SettingsVacation`(설정 · 부재중 자동 응답) · `MailboxManage`(메일함 관리 · 휴지통 비우기) · `MailActions4`(스팸 · 원문 · 오래 걸리는 처리) — 아래 "4단계"
· 5단계: `SettingsNotify`(설정 · 알림) · `MailRealtime`(실시간 새 메일 · 바탕화면 알림 상태) — 아래 "5단계"
· 6단계: `SettingsFilter`(설정 · 필터 — 고치기 창) · `MailFilterStates`(메일 필터 상태 · 메일에서 바로 만들기) — 아래 "6단계"
- 파일 형식은 캔버스 전용(`<x-dc>` 마크업 + `DCLogic` 클래스)이다. 그대로 쓰지 말고 **구조·간격·색·문구의 기준**으로만 본다.
`Compose` 의 툴바·메뉴는 눌러 볼 수 있는 시안이다 — 버튼 상태만 바뀌고 본문 글자에 서식이 입혀지지는 않는다.
@@ -346,6 +347,31 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 다른 탭을 보는 동안에도 탭 제목의 "(N)" 이 바로 바뀐다.
- 실시간 연결이 끊기면 화면에는 알리지 않고 1분 확인으로 돌아간다(설정 › 알림의 연결 상태로 볼 수 있다).
## 6단계 — 메일 필터 (2026-10-28 확정 — `SettingsFilter` · `MailFilterStates`)
받은 메일을 조건에 따라 메일함으로 옮기고, 읽음 · 중요로 표시하고, 동료에게 전달한다. 규칙과 엔진 쪽 사실은 계약 2절 "메일 필터".
### 설정 › 메일 › 필터
- 메뉴는 메일 묶음의 서명 · 부재중 자동 응답 · **필터** · 알림 (깔때기 아이콘). 머리는 "필터" + 설명 한 줄 + 오른쪽 [새 필터](브랜드 색).
- 목록은 테두리 상자 하나에 행 60 — 순서 번호 · 켜기 스위치(36×20) · 이름(굵게)과 요약 한 줄 · ▲ ▼(32×40, 밀도 예외 — 메일함 ⋯ 와 같다). 행을 누르면 고친다.
요약은 "보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처" — 조건은 " 그리고 " / " 또는 ", 할 일은 " · ". 이름이 없으면 조건이 이름 자리에 온다.
꺼 둔 규칙은 글자를 흐리게. 옮길 메일함이 지워진 규칙은 빨간 안내 한 줄 "옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요."
- 목록 아래 "필터 N개 · 50개까지 만들 수 있습니다." 와 안내 상자(차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작).
### 고치기 창 (모달 620)
- 이름(선택) → "어떤 메일에"(조건이 모두 · 하나라도 · 모든 메일) → 조건 줄(어디에서 116 · 포함/포함하지 않음 132 · 글 · ✕ 36, 5개까지 [조건 더하기])
→ "할 일"(메일함으로 옮기기 + 메일함 고르기 · 읽음으로 표시 · 중요 표시 · 동료에게 전달 + 사내 주소) → 안내 띠 → [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
- 메일함 고르기에는 받은편지함 · 내 메일함 · 스팸메일함 · 휴지통(보낸편지함 · 임시보관함 없음 — 보관함은 구현하며 더했다, 아래 "6단계 구현하며 정한 보완"). 지워진 메일함이면 "(지워진 메일함) — 다른 메일함을 고르세요"가 골라진 채로.
- 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 바깥 주소 전달 · 할 일 없음 (`MailFilterStates` 의 모음).
### 상태 · 들어오는 길 (`MailFilterStates`)
- 필터가 없을 때 · 다른 곳에서 먼저 바꿨을 때(목록 위 노란 줄 + [다시 불러오기]) · 동작하지 않을 때(+ [다시 켜기]) · 50개를 다 만들었을 때 · 알림 문구.
- 읽기 화면 ⋯ 메뉴에 「이런 메일로 필터 만들기」(원문 내려받기 아래) — 설정 › 필터의 새 필터 창이 보낸 사람의 **주소**로 채워져 열린다.
- 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다(목록이 바로 바뀐다). 저장 "필터를 저장했습니다.", 지우기 "‘거래처 메일’ 필터를 지웠습니다. [되돌리기]".
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -473,3 +499,36 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
"보냈지만 N명은 받지 못했습니다 — 주소 (까닭)"와 [보낸편지함 보기](보냈다는 알림과 같은 링크) — 두 명까지 주소 · 까닭을 적고 나머지는 " 외 N명". 까닭 문구 "없는 주소" · "받는 쪽이 거절" · "지금은 받을 수 없음".
- **모두 받지 못했을 때**: 보내지 않은 것이다 — 없는 주소 · 거절(`MAIL_010`)은 받는 사람 오류(`MAIL_002`)와 같은 안내 띠 · 같은 초점으로, 지금은 받을 수 없음(`MAIL_011`)은 오류 알림으로. 쓰던 메일은 그대로 둔다.
- **부재중 자동 응답 길이**: 제목 150자 · 내용 600자(입력칸 한도(maxlength)도 이 값, 켤 때 검사 — 꺼 둔 채로는 4단계 한도까지 저장된다) — 엔진이 바이트로 막아 한글로는 4단계의 200자 · 4,000자가 저장되지 않았다(계약 2절 "부재중 자동 응답").
### 6단계 구현하며 정한 보완 (2026-10-28 — 캔버스에는 아직 없다)
- **되돌리기**: 지우기 전 목록을 통째로 되돌리지 않고, 지운 필터만 지운 자리에 다시 넣어 저장한다 — 10초 안에 다른 필터를 켜거나 옮긴 것이 사라지지 않게(보드는 "지우기 직전의 목록 그대로").
자리는 앞 · 뒤 필터로 기억한다(앞 필터 뒤 → 없으면 뒤 필터 앞 → 없으면 지운 때의 차례) — 여러 개를 어떤 차례로 되돌려도 원래 차례가 된다.
여러 알림의 [되돌리기]는 누른 차례대로, 돌고 있는 저장이 끝난 뒤에 보낸다. 다시 넣으면 50개를 넘을 때는 보내지 않고 50개 안내를 오류 알림으로.
필터 화면을 떠난 뒤에 실패하면(다른 곳에서 바뀜 포함) 알림으로 알린다.
- **다른 곳에서 바뀜**: 고치기 창 안(단추 줄 위)에도 목록 위 줄과 같은 문구와 [다시 불러오기](36 — 밀도 예외)를 둔다 — 창이 모달이라 목록 위 줄을 누를 수 없다.
창에서 다시 불러오면 창과 쓴 내용은 그대로이고 초점은 [저장]으로 간다. 다음 [저장]은 다시 받은 목록에 넣는다 —
같은 필터의 자리, 그사이 다른 곳에서 지워졌으면 맨 아래(그때 50개를 넘으면 창 안에 50개 안내). 안내는 다시 받기 전까지 남는다(칸을 고쳐도).
다시 받은 목록에 이미 없는 필터에서 [이 필터 지우기]를 누르면 보내지 않고 창을 닫는다. 목록 위 줄의 [다시 불러오기]는 보드대로 창이 닫혀 있을 때 쓴다.
- **켜기 스위치**: 이름은 "‘…’ 필터 사용" 하나 — 켜짐 · 꺼짐은 `aria-checked` 가 알린다(보드는 이름을 끄기 · 켜기로 바꿨다). 그림은 36×20, 누르는 곳은 40×44(밀도 예외).
- **누를 수 없는 단추는 `aria-disabled`**(누른 단추 · 창이 돌아올 단추가 초점을 잃지 않게): 맨 위 ▲ · 맨 아래 ▼, 저장 중의 스위치 · ▲▼, 창의 [저장] · [이 필터 지우기],
50개일 때의 [새 필터](까닭 글을 `aria-describedby`). 마우스를 올려도 모양이 바뀌지 않는다.
메뉴의 꺼진 항목도 모두 그렇다 — 편집기 메뉴 · 이동 · 메일함 ⋯ · 읽기 화면 ⋯ 에서 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 이름 아래 까닭 글은 `aria-describedby`.
- **저장은 한 번에 하나**: 저장이 도는 동안 누른 다른 행의 스위치 · ▲▼ 는 보내지 않는다(모양은 그대로, 커서만 바뀐다).
창으로 저장할 때 켜짐은 그 순간 목록의 값이다 — 창은 켜기를 고치지 않으므로, 창을 연 뒤 실패해 되돌려진 켜기가 창의 저장으로 살아나지 않게.
- **앞뒤 공백**: 이름 · 찾을 글 · 전달 주소는 앞뒤 공백을 빼고 보낸다(서버도 뺀 값으로 저장한다).
- **문구**(보드에 없던 것): "내 주소로는 전달할 수 없습니다." · "전달할 주소를 입력해 주세요." · "전달할 주소 형식이 올바르지 않습니다."
(@ 앞은 RFC 5322 dot-atom · 64자 — 서버의 검사보다 좁게 봐서, 화면이 통과시킨 주소를 서버가 거절하지 않게). 길이 · 제어 문자 · 이름 오류는 사전의 `VAL_001` 문구.
이름 없는 필터의 이름(조건으로 만든 것)은 알림 · 단추 이름에서 50자로 자르고 "…"를 붙인다. 작은따옴표가 든 이름은 큰따옴표로 묶는다.
- **읽기 화면 ⋯**: 50개인지 알려고 메뉴를 열 때 필터를 알림 없이 받는다(받아 둔 것이 없거나 50개일 때). 결과가 오기 전에는 항목을 켜 둔다.
50개면 항목을 끄고 이름 아래에 까닭 한 줄. 보낸 사람 주소가 없는 메일에는 항목이 없다. `?from=` 으로 왔는데 50개면 창을 열지 않고 주소의 쿼리만 지운다.
- **화면 폭**: 공용 설정 틀 그대로 최대 800 · 묶음 사이 22(보드는 880 · 18 — 다른 설정 화면과 맞춘다). 머리의 [새 필터]는 틀의 `actions` 자리. 안내 상자 700, 창 620.
- **메일함**: 받아 둔 메일함 목록을 보이며 뒤에서 다시 받는다(다른 탭에서 지웠을 수 있다). 요약에 메일함 이름이 들어가 필터와 메일함을 둘 다 받은 뒤 목록을 그린다.
옮길 곳 고르기는 받은편지함 → 내 메일함 → **보관함** → 스팸메일함 → 휴지통(보관함과 그 아래 — 보드에 없었다. 서버가 받고, mailcow 에서 옮겨 온 계정에 있다).
지워진 메일함은 "(지워진 메일함)…"이 꺼진 채 골라져 보이고, 그대로 저장해도 막지 않는다(서버가 받는다 — 받은편지함에 들어온다).
- **초점**: 조건 더하기 → 새 줄의 "어디에서", 조건 빼기 → 이웃 줄의 찾을 글, 필터 지우기 → 다음 행(마지막이면 앞 행),
목록 위 [다시 불러오기] · [다시 켜기] · `?from=` 으로 연 창의 [취소] · 이미 없는 필터 지우기 → 머리의 [새 필터], 창 안 [다시 불러오기] → [저장].
- **그 밖**: [다시 켜기]가 성공해도 알림은 없다(줄이 사라진다). 목록 아래 줄은 보드 글 그대로 "… 행을 누르면 고칩니다."까지.
전달 칸은 `type=email` · 254자이고, 보기 글(받는 사람 칸 · 전달 칸)은 로그인한 직원 주소의 도메인으로 만든다.
저장하는 사이 화면을 떠나면 칸 안에 보일 거절을 알림으로 띄운다(부재중 화면과 같다). 목록에 `aria-busy` 를 걸지 않는다(칸마다 `aria-disabled`).
- **하지 않은 것**: 고치기 창에 쓰다 만 채로 떠날 때 묻기(나가기 확인) — 보드에 없다. 창을 연 채 브라우저 뒤로 가기를 하면 쓴 것을 잃는다(알려진 한계).
+447
View File
@@ -0,0 +1,447 @@
<!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}
.toast-action:hover{background:rgba(255,255,255,0.1)}
</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>
<sc-if value="{{ r.missing }}" hint-placeholder-val="{{ false }}">
<span style="display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: #B42318">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span>옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.</span>
</span>
</sc-if>
</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>스팸으로 판정된 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.</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="2">
<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="gone" selected="{{ selGone }}" disabled>(지워진 메일함) — 다른 메일함을 고르세요</option>
<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>
<p style="margin: 0; padding: 10px 12px; border-radius: 8px; background: #F8F9FB; font-size: 12.5px; color: #4A5363">새로 오는 메일에만 적용됩니다 — 이미 받은 메일은 그대로입니다. 스팸으로 판정된 메일에는 적용하지 않습니다.</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="{{ hasToast }}" hint-placeholder-val="{{ false }}">
<div role="status" style="position: fixed; right: 20px; bottom: 20px; z-index: 20; width: min(420px, 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)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">{{ toastText }}</span>
<sc-if value="{{ toastUndo }}" hint-placeholder-val="{{ false }}">
<button type="button" class="on-dark toast-action" onClick="{{ undo }}" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
</sc-if>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":900}}'>
class Component extends DCLogic {
state = {
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: 'gone', read: false, flag: false, fwd: '' },
],
edit: null,
toast: null,
};
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 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: () => this.setState({ edit: { index: i, d: JSON.parse(JSON.stringify(r)), error: null }, 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',
missing: Boolean(r.move) && !boxName(r.move),
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 d = e ? e.d : { name: '', match: 'all', conds: [{ f: 'from', op: 'has', v: '' }], move: null, read: false, flag: false, fwd: '' };
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 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 rule = { ...d, 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));
this.setState({ rules: next, edit: null, toast: { text: '필터를 저장했습니다.' } });
};
const removed = e && e.index !== null ? st.rules[e.index] : null;
return {
rules,
count: n,
openNew: () => this.setState({ edit: { index: null, d, error: null }, 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 }),
selGone: Boolean(d.move) && !boxName(d.move), 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: d.fwd !== '' || Boolean(e && e.fwdOn), noFwd: d.fwd === '' && !(e && e.fwdOn),
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'),
canDelete: Boolean(e && e.index !== null),
remove: () => this.setState({ rules: st.rules.filter((_, k) => k !== e.index), edit: null, toast: { text: `‘${titleOf(removed)}’ 필터를 지웠습니다.`, undo: st.rules } }),
save,
cancel: () => this.setState({ edit: null }),
hasToast: Boolean(st.toast),
toastText: st.toast ? st.toast.text : '',
toastUndo: Boolean(st.toast && st.toast.undo),
undo: () => this.setState({ rules: st.toast.undo, toast: { text: '필터를 되돌렸습니다.' } }),
};
}
}
</script>
</body>
</html>
+6 -3
View File
@@ -22,15 +22,18 @@
"MailboxManage.dc.html": { "x": 3040, "y": 6640, "w": 1440, "h": 900, "title": "메일함 관리 · 휴지통 비우기", "expand": "fill", "is_interactive": true },
"MailActions4.dc.html": { "x": 4560, "y": 6640, "w": 1440, "h": 1320, "title": "스팸 · 원문 · 오래 걸리는 처리 상태" },
"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": "실시간 새 메일 · 바탕화면 알림 상태" }
"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": "메일 필터 상태 · 메일에서 바로 만들기" }
},
"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"],
"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"],
"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 }
"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 }
},
"designSystems": []
}
+106 -3
View File
@@ -8,7 +8,7 @@
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `SYS_001`
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `SYS_001`
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
@@ -209,7 +209,9 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacation` (아래 "부재중 자동 응답") | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체) | 200 `MailVacation` | 400 `VAL_001`, 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체) | 200 `MailVacation`(저장한 값) | 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` |
| `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` |
@@ -244,6 +246,13 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| 첨부 20개 초과 | 400 `MAIL_006` — 클라이언트는 첨부를 추가할 때 미리 막는다 |
| 본문이 너무 김 — 텍스트 100,000자·서식 본문 500,000자 초과(JS 문자열 길이, UTF-16 단위), 서식 본문이 정화 전 비용 한도·정화 뒤 2,000,000바이트 초과 | 400 `MAIL_007` — 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 막고 본문 아래에 알린다 (아래 "서식 본문"의 한도) |
| JSON 본문 1MB 초과 (발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4MB — 서식 본문과 긴 인용 때문) | 413 `FILE_002` |
| JSON 본문의 모양이 한도를 넘음 — 배열 하나 1,000개 · 값(객체 · 배열 · 원시 값) 10,000개 · 깊이 32단 | 400 `VAL_001` |
| JSON 본문이 UTF-8 이 아님 — `charset` 이 `utf-16` · `utf-32` · `utf-7` 등 (`charset` 을 붙이지 않거나 `utf-8` 이면 된다) | 400 `VAL_001` |
**JSON 본문 모양 한도 (2026-10-28 — 6단계 리뷰에서, 전부터 있던 경로도 같은 원인)**: 본문 크기 한도 안에서도 작은 객체 수만~수십만 개면 DTO 변환 · 검사가 몇 초씩 서버를 멈추고 수백 MB 를 썼다
(리뷰 측정 — 1MB 에 필터 규칙 8만 개 7.8초, 조건 15만 개는 500). 그래서 모든 JSON 본문을 DTO 에 닿기 전에 본다 — 읽기 전에 글자를 한 번 훑고, 읽은 뒤 한 번 더.
한도는 지금 계약의 어떤 본문보다 넉넉하다(가장 긴 배열 100 · 가장 깊은 본문 5단 · 값이 가장 많은 필터 50개 저장이 약 1,600개).
이 검사와 UTF-8 검사는 본문 크기 한도처럼 인증보다 앞이라, 로그인하지 않은 요청도 401 이 아니라 400 이다. JSON 은 UTF-8 만 받는다(RFC 8259 8.1 — 웹 · 앱 모두 UTF-8 로 보낸다).
메일함 정렬(확정 디자인 기준): 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(`sortOrder`, 이름 순).
@@ -683,10 +692,17 @@ interface MailVacation {
- 한 번도 설정하지 않은 계정은 `{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" }` 로 온다(엔진의 null 은 빈 문자열로). 끈 채로는 빈 제목 · 내용도 저장된다.
제목 · 내용은 쓴 그대로 저장한다 — "공백이 아닌 글자" 검사만 앞뒤 공백을 뺀 값(JS `trim()` — NBSP · U+3000 포함)으로 한다. 제어 문자 검사는 꺼 둔 채로도 한다.
- 엔진의 JMAP `VacationResponse`(singleton)를 그대로 쓴다 — `htmlBody` 는 비운다(null). 저장은 전체 교체다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
- 저장 자리 — 6단계부터 메일 필터와 같은 엔진 규칙(Sieve 스크립트 `arasystem`)에 둔다(아래 "메일 필터"의 "저장 자리"). 4단계에는 엔진의 JMAP `VacationResponse`(singleton)였다.
서식 본문은 없다(글자만). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다.
- 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve").
- 우리 스크립트가 엔진에서 꺼져 있으면(필터의 `isActive: false`) 부재중도 동작하지 않는다. 이 API 의 `isEnabled` 는 저장한 값이라 그때도 `true` 로 온다
(알려진 한계 — 드묾. 화면은 필터 화면의 줄로만 알린다).
- 저장해 둔 원본이 깨진 계정은 꺼짐(`isEnabled: false`, 빈 제목 · 내용)으로 온다. 원본이 이 서버보다 새 판이면 저장은 503 `MAIL_001` 이다("메일 필터"의 "저장 자리").
- `fromDate` · `toDate` 는 `Z` 로 끝나는 UTC 시각이고, 두 키는 늘 보낸다(값은 null 가능). 엔진에는 초 단위로 저장한다(소수 초는 버린다).
둘 다 있으면 버린 값으로 `fromDate < toDate`(아니면 400 `VAL_001`). 화면은 날짜만 고르고, 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다.
- 같은 사람에게 매번 답하지는 않는다 — JMAP 규격(RFC 8621 8절)이 "보낸 사람마다 적당한 기간에 한 번" 을 요구하고 엔진이 따른다.
6단계부터는 Sieve `vacation`(RFC 5230)이 같은 일을 한다 — 간격은 엔진 기본값이다.
- **클라이언트**: 설정 › 메일 › 부재중 자동 응답. 켜져 있으면 메일 화면의 메일함 패널 아래(사용량 위)에 "부재중 자동 응답 켜짐 · 11월 3일까지"(누르면 설정)를 보인다 —
메일 화면에 들어올 때 한 번 받고, 시간이 지나면 다시 계산한다(끝나는 시각이 지나면 숨기고, 시작 전이면 "10월 30일 ~ 11월 3일 · 설정").
@@ -778,6 +794,93 @@ data: {"reason":"token-expiring"}
같은 tag 로 앞 알림을 바꿔 쓴다(쌓이지 않는다). 누르면 그 탭으로 와서 메일을 연다(여러 통이면 받은편지함). 사용자가 웹메일로 돌아오면(어느 탭이든 보이고 초점이 생기면) 띄운 알림을 닫는다.
허락은 사용자가 누른 뒤에만 묻는다(목록 위 한 번 권하기 · 설정의 켜기 칸). `new Notification()` 을 막는 브라우저(휴대폰 Chrome)는 처음 실패할 때 "지원하지 않음"으로 바꾼다.
### 메일 필터 (`/api/mail-filters`) — 6단계
```ts
interface MailFilterSet {
rules: MailFilterRule[] // 위에서부터 차례로 적용한다 — 50개까지
version: string // 지금 규칙의 지문 — 해석하지 말고 PUT 에 그대로 돌려보낸다(그사이 다른 곳에서 규칙이 바뀌었으면 409 MAIL_012). 영문 · 숫자 · - · _ 128자까지. 규칙만 본다(부재중이 바뀌어도 그대로)
isActive: boolean // false = 우리 규칙이 엔진에서 꺼져 있거나(다른 규칙이 켜짐) 저장해 둔 원본을 읽을 수 없어 지금은 필터 · 부재중 응답이 동작하지 않는다(드묾) — 저장하면 다시 켜진다
// (아래 "저장 자리"의 새 판 원본은 예외). 한 번도 저장하지 않은 계정은 true(막힌 것이 없다)
}
interface MailFilterRule {
id: string // 클라이언트가 만든다(UUID — 영문 · 숫자 · - · _ 64자까지) — 목록 안에서 겹치지 않게
name: string // 0~50자(앞뒤 공백은 지워 저장한다), 제어 문자는 안 된다 — 비면 화면이 조건으로 이름을 보인다
isEnabled: boolean
match: 'all' | 'any' | 'always' // 조건이 모두 맞음 · 하나라도 맞음 · 모든 메일(조건 없음)
conditions: MailFilterCondition[] // all · any 는 1~5개, always 는 빈 배열
actions: MailFilterActions // 하나 이상
}
interface MailFilterCondition {
field: 'from' | 'to' | 'subject' | 'body' // 보낸 사람(이름 · 주소) · 받는 사람(받는 사람 · 참조의 이름 · 주소) · 제목 · 본문(글자 부분)
operator: 'contains' | 'notContains'
value: string // 1~200자(앞뒤 공백을 지운 값 — 그 값으로 저장한다), 제어 문자 · `${` 는 안 된다
}
interface MailFilterActions {
moveToMailboxId: string | null // 받은편지함 · 내 메일함 · 보관함 · 스팸메일함 · 휴지통 (보낸편지함 · 임시보관함은 400 VAL_001). 없는 id 도 받는다 — 지운 메일함을 가리키는 규칙이 남아 있을 수 있다
markAsRead: boolean
flag: boolean
forwardTo: string | null // 사내 주소(도메인이 `MAIL_DOMAIN` 과 똑같은 주소 — 하위 도메인은 안 된다)만, 자기 주소는 안 된다(대소문자 무시).
// 앞뒤 공백은 지워 저장한다. `${` 는 안 된다
}
```
- PUT 본문은 `{ rules, version }` 뿐이다 — `isActive` 같은 다른 키를 보내면 400 `VAL_001`(모르는 필드). 응답은 저장한 규칙(앞뒤 공백을 지운 값)과 새 `version` 이다.
- **적용 순서** — 새로 온 메일을 위의 규칙부터 차례로 견주고, 맞는 규칙의 할 일을 모두 한다(읽음 · 중요는 겹쳐 붙는다).
**메일함으로 옮기는 규칙에 맞으면 거기서 멈춘다** — 아래 규칙은 보지 않는다. 받은편지함으로 옮기는 규칙을 위에 두면 "늘 받은편지함에"가 된다.
전달은 한 통에 한 곳 — 엔진 한도(사용자 스크립트의 리다이렉트 1)라 두 번째 전달은 엔진이 조용히 버린다(2026-10-28 확인). 사본은 늘 남긴다(`redirect :copy`).
**스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다**(부재중 응답도 하지 않는다) — 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로(같은 날 확인) 막는다.
판정은 엔진의 분류 그대로다(지금 엔진 설정은 5점부터 스팸 — 아래 "만드는 Sieve"의 스팸 막기).
꺼 둔 규칙은 목록에만 있고 적용하지 않는다. 이미 받은 메일에는 적용하지 않는다.
- **저장 자리** — 엔진의 Sieve 스크립트 하나(`arasystem`)에 필터와 **부재중 자동 응답을 함께** 둔다. Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고
엔진의 VacationResponse 도 스크립트(`vacation`)라서, 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다(같은 날 확인).
- 4단계 `/api/mail-vacation` 은 계약 그대로 두고 저장 자리만 이 스크립트로 옮긴다 — 엔진의 VacationResponse 는 더 쓰지 않는다.
스크립트가 아직 없는 계정은 처음 읽을 때 VacationResponse 의 값을 가져온다(개발 데이터 이어받기). 엔진의 `vacation` 스크립트는 고칠 수 없다(엔진이 `forbidden`).
- 스크립트 머리의 주석에 규칙 · 부재중 원본(JSON — 판 번호 `v` 를 함께 둔다)을 두고, 읽을 때는 그것만 믿는다(엔진의 Sieve 를 다시 해석하지 않는다).
- 스크립트가 아직 없으면 빈 규칙 + `isActive: true`, 부재중은 엔진의 VacationResponse 값(위 이어받기).
- 스크립트는 있는데 원본이 없거나 깨졌으면 빈 규칙 + `isActive: false`, 부재중은 꺼짐 — 저장하면(필터든 부재중이든) 덮어쓴다.
- 원본의 판이 이 서버가 아는 것보다 새로우면(새 서버를 배포했다 되돌린 경우) 읽기는 위와 같지만 **쓰기는 거절한다**(503 `MAIL_001`) — 옛 서버가 새 판의 규칙을 지우지 않게.
- 쓸 때는 읽은 상태 그대로일 때만(`ifInState`) 쓰고 켠다(`onSuccessActivateScript`). 그사이 바뀌면 다시 읽어 확인한다
(쓰기 3번까지 — 시도마다 읽는 사이 글이 사라지면 그 읽기도 3번까지 다시 한다. 그래도 어긋나면 503 `MAIL_001`).
필터 저장은 다시 읽은 규칙의 지문이 `version` 과 다르면 409 `MAIL_012`(부재중 응답만 바뀐 것은 걸리지 않는다). 부재중 저장은 규칙을 그대로 두고 부재중 부분만 바꾼다.
다만 저장해 둔 규칙이 보내는 규칙과 이미 같으면 지문이 달라도 200 으로 저장한다 — 결과를 모르는 실패 뒤에 같은 요청을 다시 보낸 경우가 409 로 끝나지 않게
(꺼져 있었으면 이때 다시 켜진다).
- **만드는 Sieve** (엔진 확인 사실 — 2026-10-28):
- 조건: `from` → `header :contains "from"`, `to` → `header :contains ["to", "cc"]`, `subject` → `header :contains "subject"`, `body` → `body :text :contains`.
한국어 제목(인코딩된 머리글) · 본문 · 표시 이름 모두 맞는다. 대소문자는 ASCII 만 무시한다(엔진 기본 비교 — `i;unicode-casemap` 은 없다).
- 한 규칙은 `if` 하나: 표시(`addflag` — 옮기기보다 먼저, 엔진은 옮기는 순간의 표시만 붙인다) → 전달 → 옮기기(`fileinto :mailboxid "<id>" "INBOX"` + `stop`).
옮길 메일함이 없으면 대체 이름 "INBOX" 로 받은편지함에 들어온다.
- 스팸 막기는 엔진의 분류 표시로 한다 — `X-Spam-Score` 머리글(`spam, score=9.70` · `ham, score=2.20`)이 `spam` 으로 시작하면 아무 규칙도 · 부재중도 하지 않는다
(`header :matches "X-Spam-Score" "spam*"`). 엔진이 우리 스크립트를 돌리기 **전에** 붙인다. `X-Spam-Status: Yes|No` 는 스크립트가 끝난 뒤 실제로 들어간 곳에 따라 붙어 쓸 수 없다
(우리 규칙이 옮긴 스팸은 "No" 였다). 엔진 설정의 기준 점수(`scoreSpam` — 지금 5)를 바꿔도 이 표시가 따라간다.
- `spamtest` 는 쓰지 않는다 — 엔진의 값은 "스팸이면 10"이 아니라 점수를 정수로 내린 값(10에서 멈춤)이라, 10 으로 막으면 5~10점 스팸을 놓친다
(2026-10-28 실엔진: 2.20점 → 2, 9.70점 → 10 미만, 11.2점 → 10. 처음 구현은 10 으로 막아 9.70점 스팸을 규칙대로 옮기고 부재중 응답까지 보냈다).
- 바깥에서 같은 이름의 머리글을 넣어 와도 엔진의 머리글이 함께 남는다(하나라도 맞으면 참). 그래서 위조로 스팸을 숨길 수는 없고, 자기 메일에 규칙이 적용되지 않게만 할 수 있다
(그 메일은 엔진 판정대로 들어간다). 엔진의 스팸 필터는 바깥에서 오는(인증 없는) 메일에만 돌아 사내 메일에는 이 머리글이 없다 — 규칙이 그대로 적용된다.
- 부재중: 엔진이 하던 대로 `vacation :mime`(본문 base64) — 제목은 RFC 2047 로 인코딩해 넣는다. Stalwart 는 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼쳐서
그대로 넣으면 지워진다(같은 날 확인). 기간은 `currentdate :zone "+0000"`(엔진 시간대에 기대지 않게 — 같은 날 확인). 같은 사람에게 다시 답하는 간격은 엔진 기본값(지금과 같다).
- 규칙 값에 `${` 는 받지 않는다(400 `VAL_001`) — 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞는다. 문자열은 `"` · `\` 를 이스케이프한다.
`%{…}`(Stalwart 의 식 모양)는 펼치지 않아 막지 않는다 — `%{"a"}` · `%{}` · `%{1+1}` · `%{subject}` 모두 글자 그대로 견주었다(같은 날 확인).
- 크기: 엔진 한도는 스크립트 102,400바이트 · 문자열 4,096바이트다. 만든 스크립트가 90,000바이트를 넘으면 저장하지 않고 400 `MAIL_013`.
- 로그 — 규칙의 글(조건 값 · 전달 주소)은 남기지 않는다(검색어처럼 직원의 내용이다). 규칙 수 · 크기 · 결과만.
엔진이 스크립트를 거절하면(`invalidScript` → 500 `SYS_001`) 엔진 설명의 줄 · 칸만 남긴다 — 설명 문장에는 만난 문자열(규칙의 글)이 그대로 들어갈 수 있다.
- **클라이언트**: 설정 › 메일 › 필터(`/settings/mail/filters`, 탭 제목 "메일 필터") — 메뉴는 서명 · 부재중 자동 응답 · 필터 · 알림.
- 켜기 · 끄기 · 순서 바꾸기 · 지우기 · 고치기 모두 `PUT` 전체 교체(받은 `version` 으로). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면 되돌린다(오류 알림).
지우기는 확인 없이 하고 알림의 [되돌리기](10초)로 지운 필터를 **지운 자리에 다시 넣어** 저장한다 — 그사이 바꾼 다른 필터(켜기 · 순서 등)는 그대로 둔다.
저장은 한 번에 하나다 — 앞 저장이 도는 동안 누른 [되돌리기]는 차례로 보낸다. 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다."
- `MAIL_012` — 목록 위 줄 "다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요." + [다시 불러오기]. 고치던 창은 닫지 않는다 —
창 안에도 같은 안내와 [다시 불러오기]를 둔다(모달이라 뒤의 줄을 누를 수 없다). 창에서 다시 불러오면 쓴 내용은 그대로이고, 다음 [저장]은 다시 받은 목록에
그 필터를 넣는다(같은 id 자리 — 그사이 다른 곳에서 지워졌으면 맨 아래).
- `isActive: false` — 목록 위 줄 "지금은 필터와 부재중 자동 응답이 동작하지 않습니다…" + [다시 켜기](지금 목록 그대로 저장).
- 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 → `/settings/mail/filters?from=<보낸 사람 주소>` — 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 채워져 열린다(초점은 할 일의 첫 칸).
50개를 다 만들었는지 알려고 ⋯ 메뉴를 열 때 `GET` 을 한다(받아 둔 목록이 없거나 50개일 때만 — 알림 없이).
- 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 · `${` · 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다.
전달 주소의 앞부분은 서버(`IsEmail`)보다 좁게 본다(RFC 5322 dot-atom · 64자) — 화면이 통과시킨 주소를 서버가 거절하지 않게.
규칙이 50개면 [새 필터] · 「이런 메일로 필터 만들기」를 끄고 까닭을 보인다. 지운 메일함을 가리키는 규칙은 목록에 빨간 안내.
- 이 단계에서 하지 않는 것: 이미 받은 메일에 적용, **바깥 주소로 전달**(엔진에 SRS 가 없어 Gmail 등이 DMARC 로 거절할 수 있고, 회사 메일이 개인 메일로 빠지는 통로다 —
필요해지면 정책과 함께), 크기 · 첨부 · 날짜 조건, 정규식, 맞는 메일 미리 보기, mailcow(SOGo)의 필터 옮겨 오기(운영 전환 때 — imapsync 로는 옮겨지지 않는다).
### 이 단계에서 하지 않는 것
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),