generated from ttipo/project-base
docs: 9단계(보내기 취소 · 예약 발송 · 외부 이미지 · 인쇄 · 보낸 사람 차단) 계약과 디자인
보내기 취소 · 예약 발송(엔진이 제출을 붙잡는다 — FUTURERELEASE), 외부 이미지 중계, 인쇄, 보낸 사람 차단, 보내기 · 읽기 설정의 계약(2절)을 정하고, 확정한 캔버스 보드 넷(MailReadTools · MailPrint · MailSendLater · SettingsSendRead)을 둔다. 디자인 README 에 구현하며 정한 보완을 적었다. 리뷰(화면 한 번 · 서버 두 번)로 정한 것도 함께 적었다 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(MAIL_015 — 모든 옮기기 · 지우기 길, 확인하지 못하면 닫힌 쪽), 발송은 늘 envelope 을 직접 준다(엔진이 받지 않는 주소를 not-found 로 빼고 다시 제출 — 전에는 말없이 빠졌다), 다시 취소해도 200, 이미지 중계의 동시 수(서버 16 · 직원 6 — 받는 수가 적은 직원부터), 차단 주소는 dot-atom 만 · 다시 POST 하면 꺼진 규칙을 켠다. 엔진 사실(2026-10-29): envelope 의 거절은 제출 전체 · 첫 주소 하나, 내보내는 사본에서 Bcc 를 뗀다(개발의 Mailpit 이 보이는 Bcc 줄은 Mailpit 이 붙인다), 제출 조회의 정렬 이름은 sentAt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
<!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}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
.primary{background:#0E7C86;color:#FFFFFF}
|
||||
.primary:hover{background:#0A5F67}
|
||||
.ghost{background:transparent;color:#4A5363}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 1440px; height: 1060px; 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">⋯ › 인쇄(또는 읽기 화면에서 Ctrl+P)는 새 탭에 메일 한 통만 담은 인쇄 화면을 열고 브라우저의 인쇄 창을 바로 띄웁니다. 앱의 레일 · 메일함 · 목록 · 도구 줄은 인쇄하지 않습니다. 긴 메일은 여러 쪽으로 이어집니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 820px minmax(0, 1fr); gap: 28px; align-items: start">
|
||||
|
||||
<section aria-label="인쇄 화면 (새 탭)" style="display: flex; flex-direction: column; border: 1px solid #D0D5DD; border-radius: 12px; overflow: hidden; background: #E9ECEF">
|
||||
<div style="display: flex; align-items: center; gap: 8px; min-height: 52px; box-sizing: border-box; padding: 4px 16px; background: #FFFFFF; border-bottom: 1px solid #E6E8EC">
|
||||
<span style="flex: 1 1 auto; font-weight: 600; color: #344054">인쇄 화면 — 화면에만 보이는 줄</span>
|
||||
<button type="button" class="primary" style="display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 14px; 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="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg><span>인쇄</span></button>
|
||||
<button type="button" class="ghost" style="min-height: 40px; padding: 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">닫기</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: center; padding: 28px 0 32px">
|
||||
<article aria-label="A4 한 쪽" style="width: 595px; min-height: 760px; box-sizing: border-box; padding: 48px 52px; background: #FFFFFF; box-shadow: 0 4px 18px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column; gap: 14px; color: #111827">
|
||||
<div style="display: flex; align-items: baseline; justify-content: space-between; padding-bottom: 8px; border-bottom: 1px solid #D0D5DD; font-size: 10.5px; color: #4A5363">
|
||||
<span>ARA SYSTEM 메일</span>
|
||||
<span>김아라 <ara.kim@arasystem.it.kr></span>
|
||||
</div>
|
||||
<h2 style="margin: 4px 0 0; font-size: 17px; line-height: 1.4; font-weight: 700">Re: 메일 서버 이전 일정 공유</h2>
|
||||
<dl style="display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 3px 10px; margin: 0; font-size: 11.5px">
|
||||
<dt style="color: #4A5363">보낸 사람</dt><dd style="margin: 0">이도윤 <doyun.lee@arasystem.it.kr></dd>
|
||||
<dt style="color: #4A5363">받는 사람</dt><dd style="margin: 0">김아라 <ara.kim@arasystem.it.kr></dd>
|
||||
<dt style="color: #4A5363">참조</dt><dd style="margin: 0">인프라팀 <infra@arasystem.it.kr></dd>
|
||||
<dt style="color: #4A5363">날짜</dt><dd style="margin: 0">2026년 10월 26일 (월) 오전 10:42</dd>
|
||||
<dt style="color: #4A5363">첨부</dt><dd style="margin: 0">메일서버_전환_체크리스트.xlsx (48 KB)</dd>
|
||||
</dl>
|
||||
<hr style="margin: 2px 0; border: 0; border-top: 1px solid #E6E8EC">
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; font-size: 12.5px; line-height: 1.75">
|
||||
<p style="margin: 0">김아라님, 안녕하세요.</p>
|
||||
<p style="margin: 0">말씀하신 대로 이번 주 금요일(10/30) 저녁 9시에 MX 레코드를 새 메일 서버로 전환하겠습니다. 전환 하루 전에 DNS TTL을 300초로 낮춰 두고, 기존 서버는 2주 동안 함께 운영합니다.</p>
|
||||
<p style="margin: 0">전환 체크리스트를 첨부했으니 확인 부탁드립니다.</p>
|
||||
<p style="margin: 0">이도윤 드림</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 20px">
|
||||
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">인쇄되는 것</h2>
|
||||
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
|
||||
<li>머리 줄 — 「ARA SYSTEM 메일」과 인쇄한 직원의 이름 · 주소</li>
|
||||
<li>제목 · 보낸 사람 · 받는 사람 · 참조 · 날짜(읽기 화면에 보이는 것과 같습니다 — 숨은참조는 넣지 않습니다)</li>
|
||||
<li>첨부 — 파일 이름과 크기만(내용은 인쇄하지 않습니다)</li>
|
||||
<li>본문 — 읽기 화면과 같은 정화를 거친 그대로. 쪽 번호 · 날짜는 브라우저의 머리글 · 바닥글 설정을 따릅니다</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">이미지 · 긴 메일</h2>
|
||||
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
|
||||
<li>외부 이미지는 읽기 화면에서 보이고 있던 대로 인쇄합니다(막혀 있었으면 인쇄에도 없습니다)</li>
|
||||
<li>본문은 인쇄할 폭(A4)에 맞춰 다시 잰 뒤 인쇄합니다 — 긴 메일은 여러 쪽으로 이어지고 끝에 빈 쪽이 생기지 않습니다</li>
|
||||
<li>인쇄 창을 닫아도 인쇄 화면 탭은 남습니다 — [인쇄]로 다시, [닫기]로 탭을 닫습니다</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">못 열 때</h2>
|
||||
<p style="margin: 0; color: #344054">메일을 찾을 수 없으면 인쇄 화면에 "메일을 찾을 수 없습니다"와 [닫기], 받지 못하면 [다시 시도]가 보이고 인쇄 창은 띄우지 않습니다. 세션이 끝났으면 로그인 화면으로 갑니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1060}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,172 @@
|
||||
<!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}
|
||||
.primary{background:#0E7C86;color:#FFFFFF}
|
||||
.primary:hover{background:#0A5F67}
|
||||
.ghost{background:transparent;color:#4A5363}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.danger-solid{background:#B42318;color:#FFFFFF}
|
||||
.danger-solid:hover{background:#912018}
|
||||
.tool{background:transparent;color:#344054}
|
||||
.tool:hover{background:#F2F4F7;color:#171C26}
|
||||
.notice-btn{background:#FFFFFF;color:#7A5200;border:1px solid #F0D9A8}
|
||||
.notice-btn:hover{background:#FFF1D6}
|
||||
.menu-item{background:transparent;color:#344054}
|
||||
.menu-item:hover{background:#EEF0F3}
|
||||
.toast-action{background:transparent;color:#9ED6DB}
|
||||
.toast-action:hover{background:rgba(255,255,255,0.1)}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 1440px; height: 1220px; 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">외부 이미지는 지금처럼 막아 두고, 원하면 그 메일만 보이게 합니다. 이미지는 사내 서버가 대신 받아 보여 주므로 보낸 사람에게 이 PC 의 주소가 드러나지 않습니다. 인쇄 · 차단은 ⋯ 메뉴에서 합니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 860px minmax(0, 1fr); gap: 20px; align-items: start">
|
||||
|
||||
<section aria-label="읽기 화면 — 외부 이미지를 막은 메일" style="display: flex; flex-direction: column; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px; overflow: hidden">
|
||||
<div style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 24px; border-bottom: 1px solid #EEF0F3">
|
||||
<button type="button" class="tool" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"></polyline><path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg><span>답장</span></button>
|
||||
<button type="button" class="tool" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><span>전체 답장</span></button>
|
||||
<button type="button" class="tool" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 17 20 12 15 7"></polyline><path d="M4 18v-2a4 4 0 0 1 4-4h12"></path></svg><span>전달</span></button>
|
||||
<span aria-hidden="true" style="width: 1px; height: 20px; margin: 0 8px; background: #E6E8EC"></span>
|
||||
<button type="button" class="tool" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><span>스팸 신고</span></button>
|
||||
<button type="button" class="tool" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; color: #B42318; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg><span>삭제</span></button>
|
||||
<button type="button" class="tool" aria-label="더 보기" aria-haspopup="menu" aria-expanded="true" style="display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 0; border-radius: 8px; background: #EEF0F3; cursor: pointer"><svg width="16" height="16" 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="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg></button>
|
||||
</div>
|
||||
|
||||
<article aria-labelledby="rt-subject" style="display: flex; flex-direction: column; gap: 18px; box-sizing: border-box; padding: 20px 32px 32px">
|
||||
<h2 id="rt-subject" style="margin: 4px 0 0; font-size: 20px; line-height: 1.4; font-weight: 650; letter-spacing: -0.015em">[클라우드 브리프] 10월 넷째 주 — 서버 비용 줄이는 다섯 가지</h2>
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 600">클</span>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px">
|
||||
<span style="font-size: 13.5px; font-weight: 600">클라우드 브리프</span>
|
||||
<span style="font-size: 12.5px; color: #667085">news@cloudbrief.example</span>
|
||||
<span style="margin-left: auto; font-size: 12.5px; color: #667085; white-space: nowrap; font-variant-numeric: tabular-nums">2026. 10. 29. (목) 오전 8:00</span>
|
||||
</div>
|
||||
<span style="font-size: 12.5px; color: #4A5363">받는 사람 김아라 <ara.kim@arasystem.it.kr></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 8px 8px 8px 12px; border-radius: 8px; background: #FFF7E6; color: #7A5200; font-size: 12.5px">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
|
||||
<span style="flex: 1 1 auto">외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.</span>
|
||||
<button type="button" class="notice-btn" style="flex: 0 0 auto; min-height: 36px; padding: 0 12px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">이미지 보기</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; max-width: 640px; font-size: 14px; line-height: 1.75; color: #1F2733">
|
||||
<p style="margin: 0">안녕하세요, 이번 주 클라우드 브리프입니다. 서버 비용을 줄이는 다섯 가지 방법을 정리했습니다.</p>
|
||||
<p style="margin: 0">1. 쓰지 않는 디스크 정리하기 — 스냅숏만 남기고 붙어 있지 않은 볼륨을 지우면 …</p>
|
||||
<p style="margin: 0">2. 예약 인스턴스로 바꾸기 — 1년 약정이면 …</p>
|
||||
<p style="margin: 0; color: #667085">(이미지가 있던 자리는 비어 있습니다 — 글은 그대로 읽힙니다.)</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 20px">
|
||||
<div role="menu" aria-label="더 보기" style="display: flex; flex-direction: column; width: 300px; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg><span style="flex: 1 1 auto">인쇄</span><span style="font-size: 12px; color: #667085">Ctrl+P</span></button>
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg><span>원문 내려받기 (.eml)</span></button>
|
||||
<span role="separator" style="height: 1px; margin: 4px 6px; background: #EEF0F3"></span>
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg><span>보낸 사람을 주소록에 추가</span></button>
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg><span>이런 메일로 필터 만들기</span></button>
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; color: #B42318; 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"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg><span>보낸 사람 차단</span></button>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363; max-width: 380px">「인쇄」는 새 탭에 인쇄 화면을 열고 바로 인쇄 창을 띄웁니다(다음 보드). 읽기 화면에서 Ctrl+P 를 눌러도 같습니다. 이미 차단한 보낸 사람이면 마지막 항목이 「보낸 사람 차단 풀기」입니다. 사내 직원 · 내 주소는 차단 항목이 없습니다.</p>
|
||||
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="blk-title" style="width: 400px; box-sizing: border-box; padding: 18px 20px 14px; 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: 14px">
|
||||
<h2 id="blk-title" style="margin: 0; font-size: 15px; font-weight: 700">보낸 사람 차단</h2>
|
||||
<p style="margin: 0; font-size: 13px; color: #344054">앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다.</p>
|
||||
<div role="radiogroup" aria-label="차단할 범위" style="display: flex; flex-direction: column; gap: 4px">
|
||||
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px 10px; border: 1px solid #0E7C86; border-radius: 8px; background: #F2FAFA; cursor: pointer">
|
||||
<input type="radio" name="blk-scope" checked style="width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
|
||||
<span style="display: flex; flex-direction: column; line-height: 1.4"><span style="font-weight: 600">이 주소만</span><span style="font-size: 12.5px; color: #4A5363">news@cloudbrief.example</span></span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px 10px; border: 1px solid #D0D5DD; border-radius: 8px; cursor: pointer">
|
||||
<input type="radio" name="blk-scope" style="width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
|
||||
<span style="display: flex; flex-direction: column; line-height: 1.4"><span style="font-weight: 600">이 도메인 전체</span><span style="font-size: 12.5px; color: #4A5363">@cloudbrief.example 에서 오는 메일 모두</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<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">
|
||||
<span>이 메일도 스팸메일함으로 옮기기</span>
|
||||
</label>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다.</p>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
<button type="button" class="primary" 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" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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: 12px; 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; padding: 14px; border: 1px solid #EEF0F3; border-radius: 8px">
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 120px; border-radius: 6px; background: linear-gradient(135deg, #DDEFF1, #F3E9D8); color: #4A5363; font-size: 12.5px">(받아 온 이미지)</div>
|
||||
<p style="margin: 0; font-size: 13.5px; color: #1F2733">안녕하세요, 이번 주 클라우드 브리프입니다. …</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">안내 줄은 사라집니다. 이미지를 받지 못한 자리(없어진 주소 · 너무 큰 파일 · 사내망 주소)는 비어 있습니다. 메일 안의 배경 그림은 보이지 않습니다.</p>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 12px; 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>
|
||||
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
|
||||
<li><strong>늘 묻기</strong>(처음 값) — 메일마다 [이미지 보기]</li>
|
||||
<li><strong>주소록 · 사내 직원의 메일은 늘 보기</strong> — 보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다. 나머지는 [이미지 보기]</li>
|
||||
<li><strong>늘 보기</strong> — 모든 메일에서 바로 보입니다</li>
|
||||
</ul>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">스팸메일함의 메일은 설정과 상관없이 늘 묻습니다. 답장 · 전달에 인용한 본문에는 이미지가 실리지 않습니다(지금과 같음).</p>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 10px; 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">
|
||||
<span style="flex: 1 1 auto; font-weight: 500">news@cloudbrief.example 을 차단하고 이 메일을 스팸메일함으로 옮겼습니다.</span>
|
||||
<button type="button" class="toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 8px 8px 8px 12px; border-radius: 8px; background: #F2F4F7; color: #344054; font-size: 12.5px">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg>
|
||||
<span style="flex: 1 1 auto">차단한 보낸 사람의 메일입니다.</span>
|
||||
<button type="button" class="ghost" style="flex: 0 0 auto; min-height: 36px; padding: 0 10px; border: 1px solid #D0D5DD; border-radius: 6px; background: #FFFFFF; font: inherit; font-weight: 600; cursor: pointer">차단 풀기</button>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">[되돌리기]는 차단을 풀고 메일을 받은편지함으로 돌려놓습니다. 「이 도메인 전체」는 gmail.com · naver.com 처럼 여러 사람이 쓰는 메일 서비스에서는 고를 수 없습니다(까닭을 칸 아래에).</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1220}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,203 @@
|
||||
<!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}
|
||||
.primary{background:#0E7C86;color:#FFFFFF}
|
||||
.primary:hover{background:#0A5F67}
|
||||
.ghost{background:transparent;color:#4A5363}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
|
||||
.menu-item{background:transparent;color:#344054}
|
||||
.menu-item:hover{background:#EEF0F3}
|
||||
.toast-action{background:transparent;color:#9ED6DB}
|
||||
.toast-action:hover{background:rgba(255,255,255,0.1)}
|
||||
.row:hover{background:#F6F7F9}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 1440px; height: 1500px; 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: 1000px">
|
||||
<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">[보내기]를 누르면 메일 서버가 몇 초(설정 — 처음 10초) 기다렸다가 보냅니다. 그동안 [보내기 취소]로 쓰기 화면에 돌아옵니다. 기다리는 곳이 서버라 창을 닫아도 그대로 보내집니다. 예약 발송은 같은 방식으로 7일 안의 시각까지 기다립니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 480px 420px minmax(0, 1fr); gap: 20px; align-items: start">
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 12px; 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: center; gap: 12px; min-height: 64px; box-sizing: border-box; padding: 10px 12px; border: 1px solid #EEF0F3; border-radius: 10px">
|
||||
<div style="display: inline-flex; align-items: stretch; border-radius: 8px; overflow: hidden">
|
||||
<button type="button" class="primary" style="display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px 0 18px; border: 0; font: inherit; font-weight: 600; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg><span>보내기</span></button>
|
||||
<span aria-hidden="true" style="width: 1px; background: rgba(255, 255, 255, 0.35)"></span>
|
||||
<button type="button" class="primary" aria-label="보내기 방법 더 보기" aria-haspopup="menu" aria-expanded="true" style="display: inline-flex; align-items: center; justify-content: center; width: 40px; min-height: 44px; border: 0; font: inherit; cursor: pointer; background: #0A5F67"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
|
||||
</div>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">임시 저장</button>
|
||||
<span style="margin-left: auto; font-size: 12px; color: #667085">2분 전에 저장함</span>
|
||||
</div>
|
||||
<div role="menu" aria-label="보내기 방법" style="display: flex; flex-direction: column; width: 260px; margin-top: -4px; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg><span>예약 발송…</span></button>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">메뉴는 「예약 발송…」 하나로 시작합니다. 단축키 Ctrl+Enter 는 그대로 [보내기]입니다. 보내는 중 · 저장 중 · 첨부를 올리는 중에는 두 단추가 함께 꺼집니다(지금의 [보내기]와 같은 규칙).</p>
|
||||
</section>
|
||||
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="sch-title" style="box-sizing: border-box; padding: 18px 20px 14px; 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: 14px">
|
||||
<h2 id="sch-title" style="margin: 0; font-size: 15px; font-weight: 700">예약 발송</h2>
|
||||
<div role="radiogroup" aria-label="보낼 시각" style="display: flex; flex-direction: column; gap: 4px">
|
||||
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border: 1px solid #0E7C86; border-radius: 8px; background: #F2FAFA; cursor: pointer">
|
||||
<input type="radio" name="sch" checked style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
|
||||
<span style="flex: 1 1 auto; font-weight: 600">내일 오전</span><span style="font-size: 12.5px; color: #4A5363; font-variant-numeric: tabular-nums">10월 30일 (금) 오전 9:00</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border: 1px solid #D0D5DD; border-radius: 8px; cursor: pointer">
|
||||
<input type="radio" name="sch" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
|
||||
<span style="flex: 1 1 auto; font-weight: 600">내일 오후</span><span style="font-size: 12.5px; color: #4A5363; font-variant-numeric: tabular-nums">10월 30일 (금) 오후 1:00</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border: 1px solid #D0D5DD; border-radius: 8px; cursor: pointer">
|
||||
<input type="radio" name="sch" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
|
||||
<span style="flex: 1 1 auto; font-weight: 600">다음 주 월요일</span><span style="font-size: 12.5px; color: #4A5363; font-variant-numeric: tabular-nums">11월 2일 (월) 오전 9:00</span>
|
||||
</label>
|
||||
<label style="display: flex; flex-direction: column; gap: 8px; box-sizing: border-box; padding: 10px; border: 1px solid #D0D5DD; border-radius: 8px; cursor: pointer">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><input type="radio" name="sch" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86"><span style="font-weight: 600">직접 고르기</span></span>
|
||||
<span style="display: flex; gap: 8px; padding-left: 28px">
|
||||
<input class="field" type="date" value="2026-10-30" aria-label="날짜" style="height: 40px; padding: 0 10px; flex: 1 1 auto">
|
||||
<select class="field" aria-label="시각" style="height: 40px; padding: 0 8px; flex: 0 0 130px"><option>오전 9:00</option></select>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">이 PC 의 시간(한국 표준시)으로 보냅니다 · 7일 안에서 고를 수 있습니다.</p>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
<button type="button" class="primary" 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" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">예약 시각 규칙</h2>
|
||||
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
|
||||
<li>골라 둔 시각이 이미 지났거나 5분 안이면 칸 아래 "지금부터 5분 뒤 이후의 시각을 고르세요."</li>
|
||||
<li>7일을 넘으면 "7일 안의 시각만 고를 수 있습니다." — 메일 서버의 한도입니다</li>
|
||||
<li>시각은 30분 단위로 고릅니다. 오늘 오후가 남았으면 「오늘 오후 6:00」도 첫 줄에 보입니다</li>
|
||||
<li>예약한 메일도 받는 사람 확인은 지금 합니다 — 없는 주소면 지금과 같이 칸 아래에서 막습니다</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 520px minmax(0, 1fr); gap: 20px; align-items: start">
|
||||
<section style="display: flex; flex-direction: column; gap: 10px; 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; flex-direction: column; gap: 6px; box-sizing: border-box; padding: 8px 6px 10px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<span style="flex: 1 1 auto; font-weight: 500">메일을 보냈습니다.</span>
|
||||
<button type="button" class="toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">보내기 취소</button>
|
||||
</div>
|
||||
<span aria-hidden="true" style="display: block; height: 3px; margin-right: 8px; border-radius: 2px; background: rgba(255, 255, 255, 0.18)"><span style="display: block; width: 62%; height: 100%; border-radius: 2px; background: #7FD3DA"></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: #912018; color: #FFFFFF">
|
||||
<span style="flex: 1 1 auto; font-weight: 500">보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)</span>
|
||||
<button type="button" class="toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 6px; color: #FFD8D3; font: inherit; font-weight: 600; cursor: pointer">보내기 취소</button>
|
||||
</div>
|
||||
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" 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="alert" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #912018; color: #FFFFFF">
|
||||
<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="#9ED6DB" 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><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
<span style="flex: 1 1 auto; font-weight: 500">10월 30일 (금) 오전 9:00 에 보냅니다.</span>
|
||||
<button type="button" class="toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">예약됨 보기</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">보내기 취소의 흐름</h2>
|
||||
<ol style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
|
||||
<li>[보내기] → 쓰기 화면을 닫고 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대(설정한 초 — 마우스를 올려도 시간은 멈추지 않습니다)</li>
|
||||
<li>[보내기 취소] → 서버가 보내지 않고 그 메일을 임시보관함으로 돌려놓습니다 → 쓰기 화면이 그 메일로 다시 열립니다(받는 사람 · 제목 · 본문 · 첨부 그대로)</li>
|
||||
<li>시간이 지나면 알림이 닫히고 메일은 보낸 것입니다 — 늦게 누르면 "이미 보낸 메일이라 취소할 수 없습니다."</li>
|
||||
<li>받는 사람 확인은 [보내기]를 누른 순간에 합니다 — 일부가 받지 못하면 그 알림에도 [보내기 취소]가 있어 주소를 고쳐 다시 보낼 수 있습니다</li>
|
||||
<li>설정 › 메일 › 보내기 · 읽기에서 끔 · 5 · 10 · 20 · 30초 — 끄면 지금처럼 바로 보냅니다</li>
|
||||
</ol>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">기다리는 동안에도 보낸편지함에 보입니다(날짜 칸에 "보내는 중"). 다른 화면으로 옮겨 다녀도 알림은 그대로입니다. 로그아웃 · 다른 계정 로그인 때는 알림을 닫습니다 — 메일은 그대로 보내집니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 280px 560px minmax(0, 1fr); gap: 20px; align-items: start">
|
||||
|
||||
<section aria-label="메일함 패널 — 예약됨" style="display: flex; flex-direction: column; gap: 2px; padding: 12px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<a href="#" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border-radius: 8px; color: #344054; 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"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg><span style="flex: 1 1 auto">받은편지함</span><span style="font-size: 12px; font-weight: 600; color: #344054">5</span></a>
|
||||
<a href="#" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border-radius: 8px; color: #344054; 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"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg><span style="flex: 1 1 auto">보낸편지함</span></a>
|
||||
<a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border-radius: 8px; 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"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg><span style="flex: 1 1 auto">예약됨</span><span style="font-size: 12px; font-weight: 600">2</span></a>
|
||||
<a href="#" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border-radius: 8px; color: #344054; 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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg><span style="flex: 1 1 auto">임시보관함</span></a>
|
||||
<p style="margin: 6px 4px 0; font-size: 12px; color: #667085">「예약됨」은 예약한 메일이 있을 때만 보낸편지함 아래에 보입니다. 숫자는 예약한 통 수입니다.</p>
|
||||
</section>
|
||||
|
||||
<section aria-label="예약됨 목록" style="display: flex; flex-direction: column; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px; overflow: hidden">
|
||||
<div style="display: flex; align-items: baseline; gap: 10px; padding: 14px 20px; border-bottom: 1px solid #EEF0F3">
|
||||
<h2 style="margin: 0; font-size: 16px; font-weight: 700">예약됨</h2>
|
||||
<span style="font-size: 12.5px; color: #667085">2통 · 보낼 시각 차례</span>
|
||||
</div>
|
||||
<a href="#" class="row" style="display: grid; grid-template-columns: 150px minmax(0, 1fr) 150px; align-items: center; gap: 12px; min-height: 52px; padding: 0 20px; border-bottom: 1px solid #EEF0F3; color: #171C26; text-decoration: none">
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받는 사람: 이도윤</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500">11월 첫 주 배포 일정 확인 부탁드립니다</span>
|
||||
<span style="text-align: right; font-size: 12.5px; color: #0A5F67; font-weight: 600; font-variant-numeric: tabular-nums">10월 30일 (금) 오전 9:00</span>
|
||||
</a>
|
||||
<a href="#" class="row" style="display: grid; grid-template-columns: 150px minmax(0, 1fr) 150px; align-items: center; gap: 12px; min-height: 52px; padding: 0 20px; color: #171C26; text-decoration: none">
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받는 사람: 인프라팀 외 2명</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500">[공지] 메일 서버 전환 완료 안내</span>
|
||||
<span style="text-align: right; font-size: 12.5px; color: #0A5F67; font-weight: 600; font-variant-numeric: tabular-nums">11월 2일 (월) 오전 9:00</span>
|
||||
</a>
|
||||
<p style="margin: 0; padding: 12px 20px; font-size: 12px; color: #667085; border-top: 1px solid #EEF0F3">고르는 칸 · 여러 통 처리는 없습니다(한 통씩 엽니다). 보낸 메일은 저절로 이 목록에서 빠지고 보낸편지함에 남습니다.</p>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 12px; 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: center; gap: 8px; padding: 8px 8px 8px 12px; border-radius: 8px; background: #E3F2F3; color: #0A5F67; font-size: 12.5px">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
<span style="flex: 1 1 auto; font-weight: 500">10월 30일 (금) 오전 9:00 에 보낼 메일입니다.</span>
|
||||
<button type="button" class="ghost" style="flex: 0 0 auto; min-height: 36px; padding: 0 10px; border: 1px solid #9ED6DB; border-radius: 6px; background: #FFFFFF; color: #0A5F67; font: inherit; font-weight: 600; cursor: pointer">고치기</button>
|
||||
<button type="button" class="ghost" style="flex: 0 0 auto; min-height: 36px; padding: 0 10px; border: 1px solid #9ED6DB; border-radius: 6px; background: #FFFFFF; color: #0A5F67; font: inherit; font-weight: 600; cursor: pointer">예약 취소</button>
|
||||
</div>
|
||||
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
|
||||
<li>[예약 취소] → 보내지 않고 임시보관함으로 옮깁니다 — 알림 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]</li>
|
||||
<li>[고치기] → 예약을 취소하고 쓰기 화면으로 엽니다 — 다시 [보내기] 또는 「예약 발송…」</li>
|
||||
<li>답장 · 전달 · 스팸 신고 단추는 이 메일에는 없습니다. [삭제]는 예약을 취소한 뒤 휴지통으로 옮깁니다(확인 창)</li>
|
||||
</ul>
|
||||
<div style="display: grid; grid-template-columns: minmax(0, 1fr) 150px; align-items: center; gap: 12px; min-height: 48px; padding: 0 12px; border: 1px solid #EEF0F3; border-radius: 8px">
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap"><span style="color: #4A5363">이도윤</span> · <span style="font-weight: 500">11월 첫 주 배포 일정 확인 부탁드립니다</span></span>
|
||||
<span style="text-align: right; font-size: 12.5px; color: #0A5F67; font-weight: 600">예약 · 10월 30일 오전 9:00</span>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">보낸편지함에서도 보입니다 — 날짜 칸이 "예약 · 보낼 시각"(보내기 취소를 기다리는 동안은 "보내는 중")입니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1500}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -466,6 +466,63 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **읽기 화면 ⋯**: 둘째 묶음(「이런 메일로 필터 만들기」 위)에 「보낸 사람을 주소록에 추가」 → 이름 · 주소를 채운 연락처 추가 창. 이미 내 주소록에 있으면 「주소록에서 보기」(그 연락처를 고른 주소록).
|
||||
사내 직원의 주소도 「보낸 사람을 주소록에 추가」. 보낸 사람 주소가 없는 메일은 항목이 없고, 연락처가 3,000개면 항목을 끄고 이름 아래에 까닭 한 줄.
|
||||
|
||||
## 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 · 인쇄 · 보낸 사람 차단 (2026-10-29 확정 — `MailReadTools` · `MailPrint` · `MailSendLater` · `SettingsSendRead`)
|
||||
|
||||
작고 자주 쓰는 다섯 가지를 한 번에 더한다. 계약은 `docs/mail/api-contract.md` 2절의 9단계 절들. 새 아이콘: 인쇄(printer) · 시계(clock) · 차단(원 + 빗금).
|
||||
|
||||
### 읽기 화면 — 외부 이미지 · ⋯ 메뉴 · 차단 (`MailReadTools`)
|
||||
|
||||
- **외부 이미지 줄**: 지금의 노란 안내 줄("외부 이미지를 차단했습니다. …") 오른쪽에 [이미지 보기](36 — 밀도 예외 `--size-notice-action`). 누르면 그 메일만 이미지를 보이고 안내 줄은 사라진다.
|
||||
다시 열면 설정대로다. 받지 못한 이미지 자리는 비어 있다. 메일 안의 배경 그림은 보이지 않는다(알려진 한계).
|
||||
- **설정에 따라**(설정 › 메일 › 보내기 · 읽기 › 외부 이미지): 늘 묻기(처음 값) · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음부터 보이면 안내 줄이 없다. **스팸메일함의 메일은 늘 묻는다.**
|
||||
- **⋯ 메뉴**: 「인쇄」(Ctrl+P 표시) → 「원문 내려받기 (.eml)」 → 구분선 → 「보낸 사람을 주소록에 추가」/「주소록에서 보기」 → 「이런 메일로 필터 만들기」 → 「보낸 사람 차단」(빨간 글).
|
||||
이미 차단한 보낸 사람이면 마지막 항목이 「보낸 사람 차단 풀기」. 사내 직원 · 내 주소에는 차단 항목이 없다. 차단한 주소가 500개면 항목을 끄고 이름 아래에 까닭.
|
||||
- **차단 확인 창**(모달 400): 제목 "보낸 사람 차단", 설명 "앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다."
|
||||
고르기 둘 — 「이 주소만」(주소) · 「이 도메인 전체」(@도메인) — 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등)는 도메인을 고를 수 없게 끄고 그 아래에 까닭.
|
||||
체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐), 안내 "차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다." — [차단](처음 초점 — 되돌릴 수 있는 일) [취소].
|
||||
- **차단한 뒤**: 알림 "‘주소’ 을 차단하고 이 메일을 스팸메일함으로 옮겼습니다." + [되돌리기](차단을 풀고 메일을 받은편지함으로). 스팸메일함에서 차단한 보낸 사람의 메일을 열면
|
||||
회색 안내 줄 "차단한 보낸 사람의 메일입니다." + [차단 풀기].
|
||||
|
||||
### 인쇄 화면 (`MailPrint`)
|
||||
|
||||
- ⋯ › 인쇄(또는 읽기 화면에서 Ctrl+P)는 **새 탭**에 인쇄 화면을 열고 브라우저의 인쇄 창을 바로 띄운다. 앱의 레일 · 메일함 · 목록 · 도구 줄은 인쇄하지 않는다.
|
||||
- 화면에만 보이는 위 줄: "인쇄 화면" + [인쇄](다시 띄우기) [닫기](탭 닫기). 인쇄 창을 닫아도 탭은 남는다.
|
||||
- 인쇄하는 것: 머리 줄(「ARA SYSTEM 메일」 · 인쇄한 직원의 이름 <주소>) → 제목 → 보낸 사람 · 받는 사람 · 참조 · 날짜(읽기 화면에 보이는 것 — 숨은참조는 넣지 않는다) →
|
||||
첨부(이름 · 크기만) → 가는 선 → 본문(읽기 화면과 같은 정화). 쪽 번호 · 날짜는 브라우저의 머리글 · 바닥글 설정을 따른다.
|
||||
- 외부 이미지는 읽기 화면에서 보이던 대로. 본문은 인쇄 폭(A4)에 맞춰 높이를 다시 잰 뒤 인쇄한다 — 긴 메일은 여러 쪽으로 이어지고 끝에 빈 쪽이 생기지 않는다
|
||||
(2026-10-29 확인: 지금 화면을 그대로 인쇄하면 앱 틀까지 찍히고, 화면 폭에서 잰 본문 높이 때문에 끝에 빈 쪽이 10장 생겼다).
|
||||
- 못 열 때: 메일을 찾을 수 없으면 "메일을 찾을 수 없습니다" + [닫기], 받지 못하면 [다시 시도] — 인쇄 창은 띄우지 않는다. 세션이 끝났으면 로그인 화면으로.
|
||||
|
||||
### 보내기 취소 · 예약 발송 (`MailSendLater`)
|
||||
|
||||
- **[보내기 ▾]**: 쓰기 화면의 [보내기]를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계 아이콘). Ctrl+Enter 는 그대로 [보내기].
|
||||
보내는 중 · 저장 중 · 첨부를 올리는 중에는 두 단추를 함께 끈다(지금의 [보내기]와 같은 규칙).
|
||||
- **예약 창**(모달 400): 제목 "예약 발송". 고르기 넷 — 「내일 오전」 9:00 · 「내일 오후」 1:00 · 「다음 주 월요일」 9:00 · 「직접 고르기」(날짜 칸 + 30분 단위 시각) — 줄마다 오른쪽에 날짜 · 요일 · 시각.
|
||||
오늘 오후가 남았으면 「오늘 오후」 6:00 이 첫 줄. 안내 "이 PC 의 시간(한국 표준시)으로 보냅니다 · 7일 안에서 고를 수 있습니다." [예약] [취소].
|
||||
칸 아래 오류: "지금부터 5분 뒤 이후의 시각을 고르세요." · "7일 안의 시각만 고를 수 있습니다." 받는 사람 확인은 지금처럼 보내는 순간(없는 주소면 받는 사람 칸의 띠).
|
||||
- **보낸 뒤 알림**(오른쪽 아래): "메일을 보냈습니다." + [보내기 취소] + 아래 남은 시간 막대(설정한 초 — 마우스를 올려도 멈추지 않는다) → 시간이 지나면 저절로 닫힌다.
|
||||
일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — 주소 (까닭)" + [보내기 취소](주소를 고쳐 다시 보낼 수 있게).
|
||||
[보내기 취소] → "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다." + 그 메일로 쓰기 화면(받는 사람 · 제목 · 본문 · 첨부 그대로). 늦게 누르면 "이미 보낸 메일이라 취소할 수 없습니다."
|
||||
예약 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기]. 로그아웃 · 다른 계정 로그인 때 알림은 닫는다 — 메일은 그대로 보내진다.
|
||||
- **「예약됨」**: 메일함 패널의 보낸편지함 아래(예약한 메일이 있을 때만, 시계 아이콘, 오른쪽에 통 수). 목록은 받는 사람 · 제목 · 보낼 시각(청록 글 — "10월 30일 (금) 오전 9:00") 차례,
|
||||
고르는 칸 · 여러 통 처리는 없다. 보낸 메일은 저절로 빠지고 보낸편지함에 남는다.
|
||||
- **예약한 메일을 열면**: 머리 아래 청록 안내 줄 "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." + [고치기](예약을 취소하고 쓰기 화면으로) [예약 취소](보내지 않고 임시보관함으로 —
|
||||
알림 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]). 답장 · 전달 · 스팸 신고 단추는 없다. [삭제]는 예약을 취소한 뒤 휴지통으로(확인 창).
|
||||
- **보낸편지함**: 아직 보내지 않은 메일은 날짜 칸이 "예약 · 10월 30일 오전 9:00"(청록) — 보내기 취소를 기다리는 동안은 "보내는 중".
|
||||
|
||||
### 설정 · 보내기 · 읽기 · 차단한 주소 (`SettingsSendRead`)
|
||||
|
||||
- **메뉴**: 메일 묶음에 「보내기 · 읽기」(필터 아래) · 「차단한 주소」를 더한다 — 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림.
|
||||
- **보내기 · 읽기**: 고르는 대로 저장한다(저장 단추 없음 — 머리 오른쪽에 "저장했습니다"). 서버에 저장되어 어느 PC 에서나 같다.
|
||||
- 「보내기 취소」 — 눌림 전환(36 — `.segmented`) 끔 · 5초 · 10초(처음) · 20초 · 30초 + "[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다."
|
||||
- 「외부 이미지」 — 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기, 줄마다 아래 설명) + 회색 상자
|
||||
"외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다."
|
||||
- **차단한 주소**: 머리 "차단한 주소" + "3 / 500" + 설명 "이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다."
|
||||
- 더하기 줄: 칸 "차단할 주소"(placeholder "kim@example.com 또는 @example.com") + [차단]. 칸 아래 오류: "메일 주소나 @도메인 형식으로 써 주세요." · "이미 차단한 주소입니다." ·
|
||||
"내 주소 · 사내 도메인은 차단할 수 없습니다." · "gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요." · "500개까지 차단할 수 있습니다."
|
||||
- 목록: 줄마다 주소(굵게) + 아래 작은 글 "10월 29일 차단"(도메인은 "도메인 전체 · …") + [차단 풀기](36). 누르면 바로 풀고 알림 "‘주소’ 차단을 풀었습니다." + [되돌리기].
|
||||
이미 받은 메일은 옮기지 않는다. 비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다."
|
||||
|
||||
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
|
||||
|
||||
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
|
||||
@@ -692,3 +749,55 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **창의 자리**: 주소록의 연락처 · 그룹 · 구성원 창은 주소록 화면의 틀에 둔다 — 주소록 안에서 옮겨 다녀도 닫히지 않고, 주소록을 떠나면 나가기 확인을 묻고, 세션이 바뀌면 닫는다(메일 화면의 창도 같다).
|
||||
- **서버 쪽에서 생긴 한계**(계약 3절): 엔진이 만든 뒤 uid 를 바꾸지 못하게 해서 다른 프로그램이 uid 없이 만든 카드는 그룹에 넣을 수 없다 — 고치기 창에서 그 저장은 창 안 사전 문구.
|
||||
다른 프로그램이 넣은 값이 우리 규칙 밖이면 그 칸을 고칠 때만 그 값도 규칙에 맞춰야 한다(다른 칸은 그대로 고칠 수 있다).
|
||||
|
||||
### 9단계 구현하며 정한 보완 (2026-10-29 — 캔버스에는 아직 없다)
|
||||
|
||||
- **외부 이미지**: [이미지 보기]는 받는 동안 꺼진다(`aria-disabled` · `aria-busy`). 받지 못하면 인터셉터가 알리고 줄은 그대로다. 그 메일의 본문 칸만 바꾸고(받는 사이 다른 메일을 열었으면 버린다),
|
||||
줄이 사라져 초점을 잃으면 본문 틀로 옮긴다. 본문 틀의 CSP 는 보일 때만 그림에 우리 출처의 중계 경로(`/api/mail-images/`)를 연다 — 경로까지 좁힌다(2026-10-29 리뷰).
|
||||
중계 주소로 인정하는 모양은 `/api/mail-images/<base64url 한 칸>` 뿐이다(짝: backend `TOKEN_PATTERN`). 답장 · 전달 인용에는 중계 그림도 실리지 않는다.
|
||||
- **인쇄**: 라우트 `mail-print`(앱 틀 없음 · 로그인 필요), 탭 제목은 메일 제목(인쇄 머리글 · PDF 이름으로 쓰인다). `window.open` 은 누름 처리 안에서 바로 부른다(팝업 차단을 피하려고).
|
||||
- Ctrl+P(⌘P)는 메일이 다 열려 있을 때만 가로챈다 — 본문 틀 안에서 눌러도 같고, 한글 조합 중 · 다른 수식키 · 불러오는 중이면 브라우저 인쇄 그대로다. 라틴 글자가 아닌 배열은 `code === 'KeyP'` 로도 잡는다.
|
||||
- 종이 210mm · 여백 16mm 15mm 는 @page 와 같은 값이다(둘을 함께 고친다). 본문 틀의 load(그림까지) · 글꼴 준비 · 한 프레임 뒤에 인쇄 창을 한 번만 띄운다 —
|
||||
그사이 다시 받기 시작했으면 띄우지 않고, [인쇄]는 다 잰 뒤에만 눌린다. [닫기]로 닫히지 않는 탭(주소로 바로 들어온 탭)은 메일 화면으로 간다.
|
||||
- 첨부는 목록에 보이는 것만 "이름 (크기)", 날짜는 "2026년 10월 26일 (월) 오전 10:42". 받기 실패는 화면이 그린다(알림 없음). 문구: "(제목 없음)" · "(보낸 사람 없음)" · 받는 중 "메일을 불러오는 중".
|
||||
- **보내기 취소 알림**: 마감은 설정한 초를 알면 "지금 + 그 초 − 1초", 모르면 min(sendAt, 지금 + 30초)이다(설정은 메일 화면에 들어올 때 받아 둔다 — 아는 값이 0 인데 서버가 붙잡았으면 sendAt).
|
||||
이 PC 시계로 이미 지났으면 지금처럼 [보낸편지함 보기] 알림이다. 마감이 있는 알림은 마우스 · 초점으로 멈추지 않고, 알림이 넘쳐도 먼저 닫히지 않는다. 막대는 `<progress>`(aria-hidden)다.
|
||||
- 알림 단추는 한 번만 돈다(닫히는 전환 중에 두 번 눌러도 — `takeAction`). 같은 메일의 취소는 요청 하나로 묶는다. 초점이 든 알림이 닫히면 알림에 들어오기 전 요소로(없으면 본문 영역) 돌려준다.
|
||||
- 쓰기 화면으로 가지 못하면(그 화면의 나가기 확인에서 머묾 등) 그 자리에 두고 "보내기를 취소하고 임시보관함으로 옮겼습니다." + [고치기].
|
||||
- 일부 거절 문장은 붙잡은 발송이면 "받지 못합니다"(현재형)다. 예약에서 일부 거절: "예약했지만 N명은 받지 못합니다 — 주소 (까닭)" + [예약됨 보기].
|
||||
- **[보내기 ▾]**: ▾ 이름 "보내기 방법 더 보기", 메뉴 이름 "보내기 방법". 메뉴는 아래가 모자라면 위로 연다(`usePopupPlacement` — 메일함 ⋯ 과 같은 규칙). 「예약 발송…」은 [보내기]와 같은 사전 검사를 먼저 한다(걸리면 창을 열지 않고 그 칸으로).
|
||||
- **예약 창**: 「오늘 오후」 6:00 은 5분 뒤 이후일 때만, 「다음 주 월요일」은 오늘 다음의 첫 월요일(오늘이 월요일이면 7일 뒤)이다. 예약할 수 없는 시각은 빼고, 같은 시각이 겹치면 앞의 것만 둔다.
|
||||
직접 고르기는 오늘 ~ 7일 뒤 · 30분 단위, 처음 값은 내일 오전 9:00, 검사는 [예약]을 누를 때(role=alert). 시간대 이름을 모르면 "이 PC 의 시간으로 보냅니다 · 7일 안에서 고를 수 있습니다.".
|
||||
창이 열린 동안 보낼 수 없게 되거나 세션이 바뀌면 닫는다. 서버가 `sendAt` 을 `VAL_001` 로 거절하면 창을 다시 열어 그 시각을 「직접 고르기」에 채우고 까닭(5분 · 7일 문구)을 보인다 —
|
||||
이 화면의 검사로는 맞는 시각(PC 시계가 어긋남)이면 가까운 한도로 본다. 쓰기 화면을 이미 떠났으면 알림으로.
|
||||
- **「예약됨」**: 수는 `GET /scheduled-messages?limit=1` 의 total 이다 — 메일 화면에 들어올 때 · 예약하거나 취소한 뒤 · 새 메일 확인이나 실시간 알림이 바뀜을 알릴 때 다시 묻는다.
|
||||
예약됨을 보는 동안은 0통이어도 패널 줄을 남긴다(숫자 없이). 패널 줄은 보낸편지함(과 그 하위) 바로 아래이고 ⋯ 는 없다.
|
||||
- 목록: 분할 보기 2줄(받는 사람 · 시각 / 제목), 목록 보기 한 줄 세 칸(받는 사람 150 · 제목 · 보낼 시각 150). 고르는 칸 · 별 · 여러 통 처리 · 비우기 · 알림 권하기 줄은 없다.
|
||||
빈 상태 "예약한 메일이 없습니다" / "쓰기 화면의 [보내기 ▾] › 예약 발송…으로 보낼 시각을 정할 수 있습니다.".
|
||||
- [고치기] · [예약 취소]는 하는 동안 두 단추를 함께 막고, 성공해 줄이 사라지면 초점을 옮긴다. [고치기] 뒤 "예약을 취소했습니다. 고친 뒤 다시 보내 주세요." —
|
||||
쓰기 화면으로 가지 못하면 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]. [예약 취소] 뒤에는 삭제처럼 다음 메일(분할) · 목록(목록 보기)으로.
|
||||
- **엔진이 발송을 풀 때**: 새 메일 확인 · 실시간 알림의 상태는 바뀌지 않으므로(계약 2절) 보이는 목록 · 열린 메일 · 가장 이른 예약의 시각 + 2초에 셋을 알림 없이 다시 받고,
|
||||
시각이 지난 채 남으면 5초마다 3번 더 본다(`usePendingSendRefresh`). 검색 결과는 기다리는 메일이 보일 때만, 열린 메일은 `pendingSend` 만 고친다. `MAIL_014` 를 받아도 바로 같은 셋을 다시 받는다.
|
||||
- **붙잡힌 메일(보내는 중 · 예약)** — 취소로만 보낸편지함을 떠난다(2026-10-29 리뷰, 계약 2절 `MAIL_015`):
|
||||
- 목록(분할 · 표 · 검색)의 고르기 칸은 꺼지고(`aria-disabled`) title 이 "보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요." — 하나 · 범위 · 모두 고르기가 건너뛰고
|
||||
여러 통 처리에도 들지 않는다(고른 뒤 붙잡히면 고른 것에서 뺀다). 보낸편지함 표의 날짜 칸은 title 에도 같은 글.
|
||||
- 읽기 화면은 두 종류 모두 스팸 신고 · 이동 · ⋯ 의 차단 항목이 없다. [삭제]는 확인 "보내지 않은 메일을 삭제할까요?" / "보내지 않고 휴지통으로 옮깁니다." [삭제] [취소](처음 초점 「취소」) →
|
||||
발송 취소 → 휴지통 → "보내지 않고 휴지통으로 옮겼습니다.". 창을 연 채 발송이 풀리면 창을 닫는다(보낸 메일의 영구 삭제 확인으로 바뀌지 않게).
|
||||
- 서버도 막는다(`MAIL_015` — 화면이 막지 못한 때만 보인다): 여러 통 처리는 그 메일만 남기고(`failedIds` · `skippedIds` — 지금처럼), 메일함 지우기 · 비우기는 붙잡힌 메일만 남으면
|
||||
진행 알림이 멈추고 인터셉터 알림 "보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요."(IMAP 으로 옮긴 메일에서만 생긴다 — 4단계 처리의 실패와 같은 모양).
|
||||
- **기다린 뒤 다음 메일로**: 삭제 · 스팸 · 차단 · 예약 취소처럼 응답을 기다린 뒤 옮기는 일은 그사이에도 같은 목록 · 같은 메일을 보고 있을 때만 한다(3 · 4단계의 단추도 함께 고쳤다 — 2026-10-29 리뷰).
|
||||
- **차단**:
|
||||
- 항목이 없는 때: 보낸 사람이 없을 때 · 내 주소 · 사내 도메인(그 하위 포함). 차단 목록은 메일 화면에 들어올 때 알림 없이 받고, 못 받았으면 ⋯ 를 열 때 다시 받는다(모르는 동안은 「보낸 사람 차단」).
|
||||
500개면 항목을 끄고(`aria-disabled`) "차단한 주소는 500개까지 둘 수 있습니다. 쓰지 않는 차단을 풀어 주세요.".
|
||||
- 창: 고르기 묶음 이름 "차단할 범위", 도메인을 고를 수 없는 까닭 "여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.". 「이 메일도 스팸메일함으로 옮기기」는 이미 스팸메일함이면 숨긴다.
|
||||
- 옮겼으면 삭제처럼 다음으로 간다. [되돌리기]는 차단을 풀고 **원래 메일함**으로 되돌린다(보드의 "받은편지함" 대신 — 받은편지함 밖에서 막았을 수 있다).
|
||||
도메인으로 막힌 보낸 사람의 「차단 풀기」는 그 도메인 차단을 푼다. 풀기가 404 면(이미 풀림) 알림 없이 푼 것으로 본다. 차단 · 풀기 뒤 세션이 바뀌었으면 옮기기 · 알림을 하지 않는다.
|
||||
- 조사는 `objectParticle` 로 — 영문 주소는 "을(를)", 따옴표 뒤에 붙여 쓴다(보드의 "‘주소’ 을" 과 다르다). 문구: 옮기지 않은 차단 "‘주소’을(를) 차단했습니다." ·
|
||||
되돌리기 뒤 "‘주소’ 차단을 풀고 메일을 되돌렸습니다."(옮기지 않았던 차단이면 "‘주소’ 차단을 풀었습니다.") · 풀기의 되돌리기 뒤 "‘주소’을(를) 다시 차단했습니다.".
|
||||
- **설정 › 보내기 · 읽기**: 머리 설명 "고르는 대로 저장합니다. 이 설정은 어느 PC 에서나 같습니다.", "저장했습니다"는 머리 오른쪽(`SettingsPageFrame` 의 `title-aside` 슬롯).
|
||||
칸마다 저장은 한 번에 하나 — 보내는 동안 고른 값은 기억했다가 앞 저장이 끝난 뒤 보낸 값 · 서버의 답과 다르면 보낸다. "저장했습니다"는 다 끝나고 마지막이 성공했을 때. 끝내 실패하면 서버가 확인한 값으로 되돌린다.
|
||||
- **설정 › 차단한 주소**: 검사 차례는 형식 → 내 주소 · 사내 도메인 → 메일 서비스 도메인 → 이미 차단 → 500개. 서버 거절(`VAL_001` · `MAIL_013`)은 칸 아래 사전 문구(알림 없음).
|
||||
성공하면 칸을 비우고 칸 아래에 "‘…’을(를) 차단했습니다."(`role=status` — 비어 있어도 영역은 늘 둔다). Enter 는 [차단]과 같다(`isImeComposing`). 줄을 풀면 초점은 다음 줄 → 앞 줄 → 칸.
|
||||
엔진 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(단추 없음) "지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다." —
|
||||
차단 · 풀기가 성공하면 켜진 것으로 본다(서버가 다시 켠다 — 계약 2절).
|
||||
- **새 토큰 · 아이콘**: `--color-on-brand-track` · `--color-on-brand-progress` · `--color-on-brand-divider` · `--color-warning-border`, 아이콘 printer · clock · block(원 + 빗금).
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<!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}
|
||||
.primary{background:#0E7C86;color:#FFFFFF}
|
||||
.primary:hover{background:#0A5F67}
|
||||
.ghost{background:transparent;color:#4A5363}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
|
||||
.field:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
|
||||
.field.is-error{border-color:#B42318}
|
||||
.nav-item{color:#344054}
|
||||
.nav-item:hover{background:#F2F4F7}
|
||||
.seg{background:transparent;color:#344054}
|
||||
.seg:hover{background:#F2F4F7}
|
||||
.unblock{background:#FFFFFF;color:#344054;border:1px solid #D0D5DD}
|
||||
.unblock:hover{background:#F2F4F7}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 1440px; height: 1000px; 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: 1000px">
|
||||
<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">메일 묶음에 두 화면을 더합니다. 「보내기 · 읽기」는 고르는 대로 저장합니다(저장 단추 없음 — 알림 설정과 같은 결). 이 설정은 서버에 저장되어 어느 PC 에서나 같습니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 220px 560px minmax(0, 1fr); gap: 20px; align-items: start">
|
||||
|
||||
<nav aria-label="설정" style="display: flex; flex-direction: column; gap: 2px; padding: 12px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<span style="padding: 6px 10px 2px; font-size: 12px; font-weight: 600; color: #667085">계정</span>
|
||||
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">비밀번호</a>
|
||||
<span style="padding: 10px 10px 2px; font-size: 12px; font-weight: 600; color: #667085">메일</span>
|
||||
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">서명</a>
|
||||
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">부재중 자동 응답</a>
|
||||
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">필터</a>
|
||||
<a href="#" aria-current="page" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none">보내기 · 읽기</a>
|
||||
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">차단한 주소</a>
|
||||
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">알림</a>
|
||||
</nav>
|
||||
|
||||
<section aria-labelledby="sr-title" style="display: flex; flex-direction: column; gap: 22px; padding: 24px 28px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; align-items: baseline; gap: 12px">
|
||||
<h2 id="sr-title" style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">보내기 · 읽기</h2>
|
||||
<span role="status" style="margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: #067647"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>저장했습니다</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 10px">
|
||||
<h3 id="undo-label" style="margin: 0; font-size: 14px; font-weight: 700">보내기 취소</h3>
|
||||
<div role="radiogroup" aria-labelledby="undo-label" style="display: inline-flex; align-self: flex-start; gap: 2px; padding: 2px; border: 1px solid #D0D5DD; border-radius: 8px">
|
||||
<button type="button" role="radio" aria-checked="false" class="seg" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">끔</button>
|
||||
<button type="button" role="radio" aria-checked="false" class="seg" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">5초</button>
|
||||
<button type="button" role="radio" aria-checked="true" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; background: #E3F2F3; color: #0A5F67; font: inherit; font-weight: 600; cursor: pointer">10초</button>
|
||||
<button type="button" role="radio" aria-checked="false" class="seg" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">20초</button>
|
||||
<button type="button" role="radio" aria-checked="false" class="seg" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">30초</button>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.</p>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 0; border: 0; border-top: 1px solid #EEF0F3">
|
||||
|
||||
<fieldset style="display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; border: 0">
|
||||
<legend style="margin: 0 0 6px; padding: 0; font-size: 14px; font-weight: 700">외부 이미지</legend>
|
||||
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 40px; padding: 8px 0; cursor: pointer">
|
||||
<input type="radio" name="img" checked style="width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
|
||||
<span style="display: flex; flex-direction: column"><span style="font-weight: 600">늘 묻기</span><span style="font-size: 12.5px; color: #4A5363">메일마다 [이미지 보기]를 눌러 봅니다</span></span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 40px; padding: 8px 0; cursor: pointer">
|
||||
<input type="radio" name="img" style="width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
|
||||
<span style="display: flex; flex-direction: column"><span style="font-weight: 600">주소록 · 사내 직원의 메일은 늘 보기</span><span style="font-size: 12.5px; color: #4A5363">보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다</span></span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 40px; padding: 8px 0; cursor: pointer">
|
||||
<input type="radio" name="img" style="width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
|
||||
<span style="display: flex; flex-direction: column"><span style="font-weight: 600">늘 보기</span><span style="font-size: 12.5px; color: #4A5363">스팸메일함의 메일은 이 설정과 상관없이 묻습니다</span></span>
|
||||
</label>
|
||||
<p style="margin: 4px 0 0; padding: 10px 12px; border-radius: 8px; background: #F6F7F9; font-size: 12.5px; color: #4A5363">외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.</p>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="bl-title" style="display: flex; flex-direction: column; gap: 16px; padding: 24px 28px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 4px">
|
||||
<div style="display: flex; align-items: baseline; gap: 10px">
|
||||
<h2 id="bl-title" style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">차단한 주소</h2>
|
||||
<span style="font-size: 12.5px; color: #667085">3 / 500</span>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="bl-add" style="font-size: 12.5px; font-weight: 600; color: #4A5363">차단할 주소</label>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<input id="bl-add" class="field is-error" type="text" inputmode="email" value="kim@" placeholder="kim@example.com 또는 @example.com" aria-invalid="true" aria-describedby="bl-add-err" style="flex: 1 1 auto; height: 44px; padding: 0 12px">
|
||||
<button type="button" class="primary" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">차단</button>
|
||||
</div>
|
||||
<p id="bl-add-err" style="margin: 0; font-size: 12.5px; color: #B42318">메일 주소나 @도메인 형식으로 써 주세요.</p>
|
||||
</div>
|
||||
|
||||
<ul style="list-style: none; margin: 0; padding: 0; border: 1px solid #EEF0F3; border-radius: 8px">
|
||||
<li style="display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 8px 0 14px; border-bottom: 1px solid #EEF0F3">
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35"><span style="font-weight: 600; overflow: hidden; text-overflow: ellipsis">news@cloudbrief.example</span><span style="font-size: 12px; color: #667085">10월 29일 차단</span></span>
|
||||
<button type="button" class="unblock" style="flex: 0 0 auto; min-height: 36px; padding: 0 12px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">차단 풀기</button>
|
||||
</li>
|
||||
<li style="display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 8px 0 14px; border-bottom: 1px solid #EEF0F3">
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35"><span style="font-weight: 600">@promo-mail.example</span><span style="font-size: 12px; color: #667085">도메인 전체 · 10월 21일 차단</span></span>
|
||||
<button type="button" class="unblock" style="flex: 0 0 auto; min-height: 36px; padding: 0 12px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">차단 풀기</button>
|
||||
</li>
|
||||
<li style="display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 8px 0 14px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35"><span style="font-weight: 600">sales@unknown-shop.example</span><span style="font-size: 12px; color: #667085">10월 18일 차단</span></span>
|
||||
<button type="button" class="unblock" style="flex: 0 0 auto; min-height: 36px; padding: 0 12px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">차단 풀기</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 8px; background: #F6F7F9; color: #4A5363; font-size: 12.5px">
|
||||
<span>칸 아래 안내(보드에 없던 경우): "이미 차단한 주소입니다." · "내 주소 · 사내 도메인은 차단할 수 없습니다." · "gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요." · "500개까지 차단할 수 있습니다."</span>
|
||||
<span>[차단 풀기]는 바로 풀고 알림 "‘news@cloudbrief.example’ 차단을 풀었습니다." + [되돌리기]. 이미 받은 메일은 옮기지 않습니다.</span>
|
||||
<span>차단한 주소가 없으면 목록 자리에 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다."</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1000}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,9 +29,13 @@
|
||||
"MailFilterApplyStates.dc.html": { "x": 1520, "y": 12700, "w": 1440, "h": 1280, "title": "필터 적용 진행 · 결과 · 나가기 확인 · 부재중 안내" },
|
||||
"Contacts.dc.html": { "x": 0, "y": 14580, "w": 1440, "h": 900, "title": "주소록 · 내 연락처 · 그룹 · 사내", "expand": "fill", "is_interactive": true },
|
||||
"ContactsEdit.dc.html": { "x": 1520, "y": 14580, "w": 1440, "h": 1640, "title": "연락처 추가 · 고치기 · 그룹 · 상태" },
|
||||
"ContactsMail.dc.html": { "x": 3040, "y": 14580, "w": 1440, "h": 1180, "title": "메일과 함께 — 받는 사람 추천 · 주소록에 추가" }
|
||||
"ContactsMail.dc.html": { "x": 3040, "y": 14580, "w": 1440, "h": 1180, "title": "메일과 함께 — 받는 사람 추천 · 주소록에 추가" },
|
||||
"MailReadTools.dc.html": { "x": 0, "y": 16820, "w": 1440, "h": 1220, "title": "읽기 화면 — 외부 이미지 · 인쇄 · 보낸 사람 차단" },
|
||||
"MailPrint.dc.html": { "x": 1520, "y": 16820, "w": 1440, "h": 1060, "title": "인쇄 화면" },
|
||||
"MailSendLater.dc.html": { "x": 3040, "y": 16820, "w": 1440, "h": 1500, "title": "보내기 취소 · 예약 발송" },
|
||||
"SettingsSendRead.dc.html": { "x": 4560, "y": 16820, "w": 1440, "h": 1000, "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", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.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", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.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 },
|
||||
@@ -40,7 +44,8 @@
|
||||
"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 },
|
||||
"phase7": { "x": 0, "y": 12400, "text": "7단계 화면 — 이미 받은 메일에 필터 적용 · 필터 창 나가기 확인 · 부재중 안내 — 2026-10-29 확정", "kind": "title1", "maxW": 2960 },
|
||||
"phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 }
|
||||
"phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 },
|
||||
"phase9": { "x": 0, "y": 16520, "text": "9단계 화면 — 보내기 취소 · 예약 발송 · 외부 이미지 보기 · 인쇄 · 보낸 사람 차단 — 2026-10-29 확정", "kind": "title1", "maxW": 6000 }
|
||||
},
|
||||
"designSystems": []
|
||||
}
|
||||
|
||||
+212
-15
@@ -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` `MAIL_012` `MAIL_013` `CONTACT_001` `CONTACT_002` `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` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001`
|
||||
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
|
||||
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
|
||||
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
|
||||
@@ -152,6 +152,7 @@ interface MessageSummary {
|
||||
isFlagged: boolean
|
||||
hasAttachment: boolean // 본문 그림(아래)만 있으면 false — 서명 그림만 붙은 메일은 첨부 표시를 하지 않는다
|
||||
size: number // 바이트
|
||||
pendingSend: PendingSend | null // 아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약)이면 그 정보 — 9단계 "보내기 취소 · 예약 발송". 그 밖에는 null
|
||||
}
|
||||
|
||||
interface MessageDetail extends MessageSummary {
|
||||
@@ -163,7 +164,8 @@ interface MessageDetail extends MessageSummary {
|
||||
references: string[]
|
||||
htmlBody: string | null // 서버가 정화한 HTML (아래 참고)
|
||||
textBody: string | null
|
||||
hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내
|
||||
hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 (보이게 했으면 false)
|
||||
imagesShown: boolean // 외부 이미지를 중계 주소로 바꿔 넣었으면 true — 9단계 "외부 이미지 보기" (`images=show` 또는 설정)
|
||||
isHtmlBodyOmitted: boolean // HTML 이 있었지만 정화 한도를 넘어 htmlBody 를 비웠으면 true (아래 "읽기 한도")
|
||||
isBodyTruncated: boolean // 보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도에서 잘렸으면 true
|
||||
attachments: MessageAttachment[]
|
||||
@@ -180,7 +182,7 @@ interface MessageAttachment extends Attachment {
|
||||
|
||||
**`htmlBody` 정화 규칙 (서버 책임)**: 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거,
|
||||
링크는 `http` · `https` · `mailto` 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`),
|
||||
외부(`http/https`) 이미지는 제거하고 `hasBlockedImages = true`.
|
||||
외부(`http/https`) 이미지는 제거하고 `hasBlockedImages = true` (보이게 할 때는 중계 주소로 바꾼다 — 아래 "외부 이미지 보기", 9단계).
|
||||
본문 안 인라인 이미지(`cid:`)는 작은 것만 `data:` URI 로 바꿔 넣고 나머지는 지운다.
|
||||
넣은 그림은 `attachments` 에 `isInline: true` 로 남는다 — 읽기 화면은 첨부 목록에서 빼고, 전달은 원문 그림을 잃지 않게 함께 붙인다
|
||||
(인용 본문에서는 그림이 빠지기 때문이다). 넣지 못한 그림(크거나 많거나 읽지 못함)은 `isInline: false` 로 첨부 목록에 보인다.
|
||||
@@ -205,24 +207,24 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
|
||||
| `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` |
|
||||
| `POST /api/mailboxes` | `{ name, parentId? }` (아래 "메일함 관리") | 201 `Mailbox` | 400 `VAL_001`, 404 `RES_001`(없는 parentId), 409 `MAIL_009`(같은 자리에 같은 이름), 503 `MAIL_001` |
|
||||
| `PATCH /api/mailboxes/{id}` | `{ name }` | 200 `Mailbox` | 400 `VAL_001`(기본 메일함), 404 `RES_001`, 409 `MAIL_009`, 503 `MAIL_001` |
|
||||
| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
|
||||
| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 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` |
|
||||
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 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` |
|
||||
| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` — 붙잡힌 메일(9단계)은 옮기지 않고 표시만 | 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` |
|
||||
| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` — `mailboxId` 와 `q` 중 하나는 있어야 한다(`q` 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` |
|
||||
| `GET /api/messages/{id}` | — | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 404 `RES_001` |
|
||||
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` |
|
||||
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
|
||||
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
|
||||
| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) |
|
||||
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과") | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) |
|
||||
| `GET /api/messages/{id}` | `images`(선택 — `show` 면 외부 이미지를 보이는 본문, 9단계 "외부 이미지 보기") | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 400 `VAL_001`(모르는 `images` · 모르는 쿼리 — 9단계부터, 전에는 무시했다), 404 `RES_001` |
|
||||
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001`, 409 `MAIL_015`(붙잡힌 메일의 `mailboxId` · `isJunk` — 9단계, 아래 "보내기 취소 · 예약 발송"), 503 `MAIL_001`(붙잡혔는지 확인하지 못함 — 바꾸지 않았다) |
|
||||
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` — 붙잡힌 메일(9단계)의 `mailboxId` · `isJunk` 는 바꾸지 않고 `failedIds` 로 | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
|
||||
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 409 `MAIL_015`(붙잡힌 메일 — 9단계), 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
|
||||
| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") — 붙잡힌 메일(9단계)은 건너뛴다(`skippedIds`) | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) |
|
||||
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 (예약은 `sendAt` — 9단계) | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과" + 붙잡아 둔 발송 `pendingSend` — 9단계) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) |
|
||||
| `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` |
|
||||
| `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` |
|
||||
| `GET /api/messages/{id}/source` | — | 받은 그대로의 메일 `message/rfc822` (표준 래퍼 없음, `Content-Disposition: attachment; filename="<제목>.eml"`) — 4단계 | 404 `RES_001`, 503 `MAIL_001` |
|
||||
@@ -234,6 +236,14 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
|
||||
| `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 10장 초과), 413 `FILE_002`(300KB 초과) |
|
||||
| `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` |
|
||||
| `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` |
|
||||
| `GET /api/scheduled-messages` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: MessageSummary[], pageInfo }` — 예약한 메일, 보낼 시각 차례, `total` 포함 (아래 "보내기 취소 · 예약 발송", 9단계) | 400 `VAL_001`, 503 `MAIL_001` |
|
||||
| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 503 `MAIL_001` |
|
||||
| `GET /api/mail-images/{token}` | — | 바깥 이미지 (표준 래퍼 없음 — 아래 "외부 이미지 보기", 9단계) | 404 `RES_001`(까닭을 가리지 않는다) |
|
||||
| `GET /api/mail-settings` | — | 200 `MailSettings` (아래 "보내기 · 읽기 설정", 9단계) | — |
|
||||
| `PATCH /api/mail-settings` | `Partial<MailSettings>` — 하나 이상 | 200 `MailSettings` | 400 `VAL_001` |
|
||||
| `GET /api/mail-blocks` | — | 200 `MailBlockList` (아래 "보낸 사람 차단", 9단계) | 503 `MAIL_001` |
|
||||
| `POST /api/mail-blocks` | `{ address }` — 주소 또는 `@도메인` | 201 `BlockedSender` (이미 있으면 200 — 꺼져 있던 규칙도 켠다) | 400 `VAL_001` · `MAIL_013`, 503 `MAIL_001` |
|
||||
| `DELETE /api/mail-blocks/{address}` | — (URL 인코딩) | 200 `{ address }` | 404 `RES_001`(차단하지 않은 주소 · 틀린 모양), 503 `MAIL_001` |
|
||||
|
||||
표에 없는 실패 (구현에서 확정):
|
||||
|
||||
@@ -303,7 +313,8 @@ interface MailQuota {
|
||||
"bodyHtml": "<div style=\"font-family:…\"><p>도윤님, 확인했습니다.</p>…</div>", // 선택 — 서식 편집기 본문, 500,000자 이하 (아래 "서식 본문")
|
||||
"attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006)
|
||||
"inReplyTo": "<답장할 메일의 id>", // 선택 — 스레드 헤더를 서버가 채운다
|
||||
"draftId": "<이어 쓴 임시 저장본 id>" // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장")
|
||||
"draftId": "<이어 쓴 임시 저장본 id>", // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장")
|
||||
"sendAt": "2026-10-30T00:00:00.000Z" // 선택 — 예약 발송: 지금부터 5분 뒤 ~ 7일 안 (9단계 "보내기 취소 · 예약 발송")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -313,6 +324,7 @@ interface MailQuota {
|
||||
interface SentMessage {
|
||||
id: string // 보낸편지함의 메일 id
|
||||
rejectedRecipients: RejectedRecipient[] // 엔진이 받지 않은 사람 — 없으면 빈 배열
|
||||
pendingSend: PendingSend | null // 엔진이 붙잡아 둔 발송이면 그 정보(보내기 취소 · 예약 — 9단계), 바로 보냈으면 null
|
||||
}
|
||||
interface RejectedRecipient {
|
||||
email: string // 요청에 쓴 주소 그대로 (받는 사람 · 참조 · 숨은참조 모두 — 같은 주소는 한 번)
|
||||
@@ -332,6 +344,23 @@ interface RejectedRecipient {
|
||||
- 결과를 읽지 못하면(엔진 오류 · 시간) 보낸 것으로 보고 `rejectedRecipients: []` 로 답한다(warn 로그) — 제출은 이미 되었다.
|
||||
결과 읽기는 발송 전체 55초 안에서 최대 5초(모두 거절일 때 사본을 지울 3초를 남긴다), 쓸 시간이 1초도 안 남으면 묻지 않는다.
|
||||
- 같은 주소(대소문자 무시)는 한 번만 싣는다 — 받는 사람 → 참조 → 숨은참조 순서이고 처음 쓴 모양 그대로.
|
||||
- **엔진이 받지 않는 주소**(9단계 리뷰 뒤 — 2026-10-29 실엔진): 발송은 늘 envelope(받는 사람 · 참조 · 숨은참조, 같은 주소는 하나)을 직접 준다.
|
||||
엔진은 envelope 에 받지 않는 주소(모르는 최상위 도메인 `x@example.cmo` · 따옴표 앞부분 `"a b"@…` 등)가 있으면 제출 **전체**를 거절하고 그 가운데 첫 주소 하나만 알려 준다.
|
||||
서버는 그 주소를 `not-found` 로 빼고 같은 메일을 나머지에게 다시 제출한다 — 붙잡기(9단계)는 그대로이고, 차례마다 **하나씩** 뺀다.
|
||||
거절 글이 따옴표로 싼 주소와 정확히 같은 받는 사람부터 찾는다 — 따옴표 앞부분 안에 다른 받는 사람의 주소가 글자로 들어 있어도 그 사람은 빼지 않는다(2026-10-29 리뷰 2).
|
||||
- 다시 내기는 20차례(뺀 주소 21개)까지다. 그래도 거절이 남고 받는 사람이 남아 있으면 사본을 지우고 400 `VAL_001`(21번째를 뺀 뒤 남은 사람이 없으면 `MAIL_010`).
|
||||
- 남는 사람이 없으면 만든 사본을 지우고 400 `MAIL_010`(아래 "모두 거절"과 같다).
|
||||
- 다시 낼 시간(발송 55초 안에서 10초 이상)이 모자라면 사본을 지우고 503 `MAIL_001` — 아직 아무것도 제출하지 않았으니 같은 키로 다시 보내면 된다.
|
||||
발송 응답은 사본 정리까지 56초 안이다(정리는 남은 시간 안에서, 남은 시간이 없어도 1초까지는 기다린다).
|
||||
- 다시 낸 제출이 요청째 거절되면(메서드 오류 · HTTP 4xx — 엔진이 실행하지 않았다) 사본을 지우고 첫 제출과 같은 오류로 끝난다.
|
||||
결과를 모르는 실패(시간 초과 · 연결 끊김 · 5xx)는 지우지 않는다 — 나갔을 수 있어 같은 키의 재시도가 맡는다(첫 제출과 같다).
|
||||
- 거절 글에 우리 주소가 없으면 지금처럼 400 `VAL_001`.
|
||||
- 전에는 envelope 을 주지 않아 엔진이 그런 주소를 아무 말 없이 빼고 나머지에게만 보냈다 — 오타 도메인으로 간 사람이 응답에 없었다.
|
||||
- 결과(`deliveryStatus`)를 읽지 못해도 envelope 에서 뺀 사람은 `rejectedRecipients` 에 싣는다. 같은 키의 재시도는 제출 기록의 envelope 에 없는 받는 사람을 `not-found` 로 다시 만든다.
|
||||
- 주소는 대소문자를 무시하고 국제화 도메인은 `xn--` 꼴로 맞춘다 — 엔진은 유니코드 · `xn--` 모두 받고 제출 기록에는 `xn--` 로 둔다.
|
||||
엔진의 거절 글은 로그에 남기지 않는다(뺀 주소만 가려서).
|
||||
- **숨은참조**: 엔진은 내보내는 사본(사내 배달 · 바깥 SMTP 모두)에서 `Bcc` 머리글을 뗀다 — 보낸 사본에만 남는다(envelope 을 주든 말든 같다).
|
||||
개발의 Mailpit 은 받은 envelope 가운데 머리글에 없는 주소를 자기 `Bcc:` 줄로 **맨 앞에 붙여** 보인다 — 엔진이 보낸 것이 아니다(머리글 없는 메일을 envelope 으로만 보내도 그렇다).
|
||||
- **모두 거절**(받은 사람이 아무도 없음) — 보내지 않은 것으로 다룬다. 이 요청이 만든 보낸 사본은 지우고(아무에게도 가지 않았다 — 그 요청이 방금 만든 메일이라 조건 없이 지운다),
|
||||
이어 쓴 저장본(`draftId`)은 지우지 않는다. 거절 가운데 5xx 가 하나라도 있으면 400 `MAIL_010`, 모두 4xx 면 503 `MAIL_011`.
|
||||
같은 키로 다시 보내면 새로 보낸다(지운 사본은 중복 방지에 걸리지 않는다).
|
||||
@@ -977,11 +1006,179 @@ interface MailFilterRun {
|
||||
"창을 닫으면 고친 내용이 사라집니다." [계속 고치기](처음 초점 · Esc) [닫기]. 다른 화면으로 갈 때는 "이 화면을 나가면 고친 내용이 사라집니다." [나가기].
|
||||
탭 닫기 · 새로고침은 브라우저 확인창. 읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다.
|
||||
|
||||
### 보내기 취소 · 예약 발송 (`POST /api/messages` 의 기다리기 · `/api/scheduled-messages`) — 9단계
|
||||
|
||||
메일 서버(엔진)가 제출을 잠시 붙잡았다가 보낸다 — `EmailSubmission` 의 FUTURERELEASE(`envelope.mailFrom.parameters` 의 `HOLDFOR` 초 · `HOLDUNTIL` 시각).
|
||||
붙잡힌 동안은 `undoStatus: pending` 이고, 취소하면(`canceled`) 배달되지 않고, 시간이 되면 보낸다(`final`) — Stalwart 0.16.27, 2026-10-29 확인.
|
||||
기다리는 곳이 서버라 창을 닫아도 보내진다. 화면은 디자인 README "9단계"(디자인 원본 `MailSendLater`).
|
||||
|
||||
```ts
|
||||
interface PendingSend {
|
||||
sendAt: string // 엔진이 보낼 시각(UTC)
|
||||
kind: 'undo' | 'scheduled' // 보내기 취소를 기다리는 중 · 예약 발송
|
||||
}
|
||||
```
|
||||
|
||||
- **보내기 취소**: `sendAt` 없이 보내면 서버가 그 직원의 설정(`undoSendSeconds` — 아래 "보내기 · 읽기 설정", 처음 10초)만큼 붙잡는다(`HOLDFOR`). 0 이면 지금처럼 바로 보낸다.
|
||||
응답 `pendingSend: { kind: 'undo', sendAt }` — 클라이언트는 남은 시간을 이 시각으로 센다. 다만 PC 시계가 어긋나면 알림이 설정보다 오래 남으므로,
|
||||
설정한 초를 알면 "지금 + 그 초 − 조금"을 넘기지 않는다(구현하며 정함 — 2026-10-29 리뷰).
|
||||
- **예약 발송**: 발송 본문의 `sendAt`(UTC) 까지 붙잡는다(`HOLDUNTIL`) — 지금부터 5분 뒤 이상 · 7일 안. 밖이면 400 `VAL_001`.
|
||||
`sendAt` 은 `Z` 로 끝나는 UTC 만 받고 소수 초는 버린다. `null` 은 없는 것과 같다(바로 보내거나 보내기 취소 설정대로).
|
||||
엔진이 그 시각을 거절하면(`forbiddenMailFrom` — 엔진 한도가 바뀐 경우 등) 400 `VAL_001` 이고 만든 보낸 사본은 지운다(구현하며 정함).
|
||||
7일은 엔진 한도다(`x:MtaExtensions` 의 `futureRelease` — 로그인 세션 `7d`. JMAP 능력의 `maxDelayedSend` 는 30일로 보이지만 엔진이 7일 넘게는 거절한다 — 2026-10-29 확인).
|
||||
붙잡은 메일의 대기열 만료는 풀리는 시각 + 3일이라 7일 안은 안전하다(같은 날 확인). 예약에는 보내기 취소 시간을 더하지 않는다. 응답 `pendingSend: { kind: 'scheduled', sendAt }`.
|
||||
- **받는 사람 확인은 제출 순간에 한다** — 붙잡아도 엔진이 사내 받는 사람을 그 자리에서 묻는다(없는 사내 주소는 곧바로 `delivered: no` · `550 5.1.2`, 반송 없음 — 2026-10-29 확인).
|
||||
그래서 위 "받는 사람별 결과"(일부 거절 `rejectedRecipients` · 모두 거절 `MAIL_010` · `MAIL_011`)는 그대로다. 바깥 주소는 풀린 뒤 배달되고, 실패하면 지금처럼 반송 메일이 온다.
|
||||
붙잡은 제출의 결과는 사내 · 바깥 모두 곧바로 `250 2.1.5 Queued` 다(거절된 사람만 `delivered: no`). 모두 거절인데 제출이 아직 기다리고 있으면(모두 4xx 등)
|
||||
같은 요청에서 먼저 취소하고 사본을 지운다 — 풀리는 시각에 다시 보내지 않게(취소를 엔진이 받지 않아도 사본은 지우고 warn). 모두 5xx 면 엔진이 붙잡아도 곧바로 `final` 이라 지우기만 한다(같은 날 확인).
|
||||
- **보낸 사본의 자리**: 제출하면서 지금처럼 보낸편지함으로 옮긴다 — 보내기 전에도 보낸편지함에 보이고 `pendingSend` 로 "보내는 중" · "예약 · 시각"을 보인다.
|
||||
예약은 사본에 내부 표시(키워드 `$ara-scheduled` — 목록 · 화면에는 표시로 내보내지 않는다. 보낸 뒤에도 남는다 — 해가 없다)를 붙여 보내기 취소를 기다리는 것과 가른다.
|
||||
`pendingSend` 는 엔진의 기다리는 제출(`undoStatus: pending`)이면서 **보낼 시각이 아직 오지 않은 것**에만 붙는다(구현하며 정함) — 붙잡지 않은 발송도 대기열에서 배달하는 동안 잠깐 pending 이고,
|
||||
보낼 시각이 지나 배달 중인 메일은 `null` 이다(그때 취소하면 `MAIL_014`). 기다리는 제출은 보낼 시각으로 찾는다(`EmailSubmission/query` 의 `filter.after: 지금` — 엔진의 색인을 탄다.
|
||||
`undoStatus` 조건은 모든 제출을 훑어 계정마다 65~140ms 라 쓰지 않는다). 차례는 보낼 시각 오름차순 — 엔진의 정렬 이름은 `sentAt` 이다(`sendAt` 은 `unsupportedSort` — 2026-10-29 확인).
|
||||
한 번에 500개까지 읽고, 닿으면 warn 을 남긴다. 목록 · 상세 · 한 통 바꾸기 응답에 엔진 호출(찾기 · 읽기)이 붙고, 읽지 못하면 `pendingSend: null` 로 준다(표시만 — 옮기기 · 지우기를 막는 확인은 닫힌 쪽으로, 아래).
|
||||
- **붙잡힌 메일은 취소로만 보낸편지함을 떠난다**(구현하며 정함 — 2026-10-29 리뷰): 붙잡힌 메일을 옮기거나 지워도 엔진은 시간이 되면 보낸다(지운 메일도 나간다) — 우리 API 로는 그렇게 되지 않게 막는다.
|
||||
- 한 통 `PATCH /api/messages/{id}` 의 `mailboxId` · `isJunk` 와 `DELETE /api/messages/{id}` 는 409 `MAIL_015`(아무것도 바꾸지 않는다), 여러 통 `PATCH /api/messages` 는 그 id 를 `failedIds` 로,
|
||||
`POST /api/message-deletions` 는 `skippedIds` 로. 읽음 · 중요 표시는 바꿀 수 있다.
|
||||
- 메일함 지우기 · 비우기(4단계 "메일함 관리")는 붙잡힌 메일을 그대로 두고 나머지를 처리한다 — 붙잡힌 메일은 `remaining` 에 들고,
|
||||
한 요청에서 처리한 것이 없으면(남은 것이 붙잡힌 메일뿐) 409 `MAIL_015` 로 끝난다(지우기는 메일함을 지우지 않는다). 우리 API 로는 붙잡힌 메일이 보낸편지함 밖에 있을 수 없어 IMAP 으로 옮긴 메일에서만 생긴다.
|
||||
- 이미 받은 메일에 필터 적용(7단계)은 붙잡힌 메일을 옮기지 않고 표시만 한다(`matched` 에는 센다).
|
||||
- 막을지 볼 때 엔진에 묻지 못하면(오류 · 시간) 바꾸지 않고 503 `MAIL_001` 이다 — 위의 모든 길이 닫힌 쪽으로 실패한다.
|
||||
다 받았는지는 응답의 `total`(`calculateTotal`)로 정하고, 모르면 다 보지 못한 것으로 본다(2026-10-29 리뷰 2 — 요청한 limit 으로 정하지 않는다).
|
||||
기다리는 제출을 찾는 조회가 500개에 닿으면 그 메일 id 로 다시 묻고(같은 "지금 뒤" 조건), 그것도 넘치면 `MAIL_001`.
|
||||
보낼 시각을 읽지 못한 기다리는 제출도 붙잡힌 것으로 본다(화면 표시 `pendingSend` 는 그때 null).
|
||||
- **알려진 한계**: IMAP · 다른 JMAP 프로그램이 옮기거나 지우는 것은 막지 못한다 — 엔진은 그래도 시간이 되면 보낸다.
|
||||
- 클라이언트는 붙잡힌 메일을 목록에서 고르지 못하게 하고(고르기 칸 `aria-disabled` + 까닭 — 모두 고르기 · 범위도 건너뛴다),
|
||||
읽기 화면에는 옮기기 · 스팸 단추를 두지 않는다 — [삭제]는 확인(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 옮긴다(보내기 취소를 기다리는 메일도 같다).
|
||||
`MAIL_015` 는 인터셉터 알림(메일함 처리는 진행 알림도 거기서 멈춘다 — 4단계와 같은 결).
|
||||
- `GET /api/scheduled-messages` — 예약한 메일(`kind: 'scheduled'` 이고 아직 보내지 않은 것)을 보낼 시각 차례로. 화면은 `pageInfo.total` 로 메일함 패널의 「예약됨」(있을 때만)을 보인다 —
|
||||
메일 화면에 들어올 때 · 보내거나 취소한 뒤 · 새 메일 확인(3단계) · 실시간 알림(5단계)이 바뀜을 알릴 때 다시 묻는다. 보낸 메일은 저절로 빠진다(엔진의 제출이 `final`).
|
||||
엔진이 풀어도 새 메일 확인 · 실시간 알림의 상태(메일함 · 메일)는 바뀌지 않는다 — 그래서 화면은 보이는 목록 · 열린 메일 · 「예약됨」 첫 항목 가운데 가장 이른 `sendAt` + 2초에
|
||||
보이는 목록 · 열린 메일 · 「예약됨」 수를 다시 맞추고, 지난 시각이 아직 보이면 5초마다 몇 번 더 본다. 취소 · 고치기가 `MAIL_014` 를 받았을 때도 같다(구현하며 정함 — 2026-10-29 리뷰).
|
||||
- `DELETE /api/scheduled-messages/{id}`(`id` 는 보낸 사본의 메일 id) — 붙잡은 발송을 취소한다(보내기 취소 · 예약 취소 모두). 엔진에서 취소한 **뒤에** 그 메일을 임시보관함으로 옮겨
|
||||
초안으로 되돌린다(`$draft` 와 취소 표시 `$ara-canceled` 를 붙이고 예약 표시를 뗀다 — 발송 지문 머리글이 남아 있어도 취소 표시가 있으면 사용자 초안이다) →
|
||||
200 `{ draftId }`(같은 메일 id — 쓰기 화면이 이 id 로 이어 쓴다). 받는 사람 · 제목 · 본문 · 첨부는 그대로다.
|
||||
이어 쓸 때(구현하며 정함): 서명 그림 부분(Content-ID `sig-…`)은 첨부에서 빼고 본문의 `cid:sig-…` 는 서명 그림 주소로 되돌린다(보낼 때 다시 `cid:` 로 — "서명"),
|
||||
답장 대상은 같은 스레드에서 `In-Reply-To` 로 찾아 초안의 `inReplyTo` 로 준다.
|
||||
- 이미 보냈거나(엔진이 풀었음 · 보낼 시각이 지나 배달 중) 기다리는 발송이 아닌 메일(제출이 없음)이면 409 `MAIL_014` — 아무것도 바꾸지 않는다. 없는 메일은 404 `RES_001`.
|
||||
- **다시 취소해도 된다**(2026-10-29 리뷰 — 응답이 끊겨 다시 누른 경우): 그 메일의 제출이 모두 `canceled` 이고 취소 표시(`$ara-canceled`)가 있으면 **어느 메일함에 있든** 그대로 200 `{ draftId }`(같은 id —
|
||||
옮기지 않는다: 사용자가 그 초안을 휴지통에 버린 뒤 늦게 온 취소가 되살리지 않게), 표시가 없으면(앞의 옮기기가 실패) 되돌리기를 다시 한다 — 그래도 못 하면 503 `MAIL_001`.
|
||||
- 엔진이 취소를 확인하지 않으면(응답의 `updated` 에 그 제출이 없음 — 거절도 아님) 옮기지 않고 503 `MAIL_001` — 옮기면 사용자는 취소된 줄 알지만 엔진은 시간이 되면 보낸다. 다시 부르면 처음부터 다시 본다.
|
||||
- 서명 그림 부분 빼기 · `cid:sig-…` 되돌리기는 취소 표시(`$ara-canceled`)가 있는 초안에만 한다(다른 메일 프로그램이 만든 초안의 그림은 건드리지 않는다).
|
||||
- 취소와 옮기기는 다른 요청이다(같은 요청에 두면 취소가 실패해도 옮겨진다). 취소는 됐는데 옮기지 못하면(엔진 오류) 한 번 더 하고, 그래도 못 하면 503 `MAIL_001` —
|
||||
그 메일은 보내지 않은 채 보낸편지함에 남는다(드묾 — 알려진 한계, warn 로그).
|
||||
- **중복 발송 방지와 함께**: 취소한 발송의 `Idempotency-Key` 로 다시 보내면 409 `MAIL_005`(보내지 않는다) — 클라이언트는 취소 뒤 새 키로 보낸다.
|
||||
서버는 취소한 발송의 메일을 "보낸 것"으로도, "끊긴 발송이 남긴 초안"으로도 보지 않는다(지우지 않는다). 취소 표시(`$ara-canceled`)가 있으면 제출 기록과 상관없이 취소한 발송이다.
|
||||
붙잡는 시간은 지문에 넣지 않는다 — 같은 키로 다시 보내면 `sendAt` 이 달라도 처음 결과(그때의 `pendingSend`)를 돌려준다.
|
||||
같은 키의 메일을 찾으면 그 제출을 늘 읽는다(엔진 호출 1회 더 — 제출이 모두 `canceled` 면 위의 `MAIL_005`).
|
||||
- 보낸 뒤 이어 쓴 저장본(`draftId`) 지우기는 지금처럼 한다 — 취소하면 붙잡혀 있던 사본이 새 초안이 된다.
|
||||
- 붙잡은 메일은 엔진을 다시 띄워도 남는다(엔진의 대기열은 저장소에 있다). 우리 서버는 붙잡은 발송을 따로 기억하지 않는다 — 목록 · 상태는 늘 엔진에 묻는다.
|
||||
- **로그**: 취소 · 예약은 log(메일 id · 보낼 시각만) — 받는 사람 · 제목은 남기지 않는다.
|
||||
- **클라이언트**: [보내기]를 누르면 지금처럼 쓰기 화면을 닫고 떠난다. 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대 — `pendingSend.sendAt` 에 닫는다(마우스를 올려도 멈추지 않는다).
|
||||
일부 거절의 실패 알림에도 [보내기 취소]를 둔다. [보내기 취소] → `DELETE` → 쓰기 화면을 `?draft=<draftId>` 로 다시 연다(새 키) + 알림 "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.".
|
||||
`MAIL_014` 는 인터셉터 알림. 예약은 알림 "‘시각’ 에 보냅니다." + [예약됨 보기]. 세션이 바뀌면 알림을 닫는다 — 메일은 그대로 보내진다.
|
||||
|
||||
### 외부 이미지 보기 (`images=show` · `/api/mail-images`) — 9단계
|
||||
|
||||
받은 메일의 외부 이미지는 지금처럼 지운 채 준다(`hasBlockedImages`). 보이게 할 때는 서버가 그 메일의 이미지 주소를 **중계 주소**로 바꿔 넣고, 그 주소로 오는 요청에
|
||||
서버가 바깥 이미지를 대신 받아 준다 — 보낸 쪽 서버에는 직원 PC 의 주소 · 브라우저가 드러나지 않고(우리 서버의 주소만), 화면의 CSP(`img-src 'self' data:`)도 그대로다.
|
||||
보낸 쪽은 이미지를 받은 시각으로 메일을 열어 본 것을 알 수 있다 — 그래서 처음 값은 "늘 묻기"다. 화면은 디자인 README "9단계"(디자인 원본 `MailReadTools` · `SettingsSendRead`).
|
||||
|
||||
- `GET /api/messages/{id}?images=show` — 외부 이미지를 보이는 본문(`hasBlockedImages: false` — srcset · 배경 그림 · 한도 밖처럼 지운 것이 있어도).
|
||||
`imagesShown` 은 실제로 바꿔 넣은 그림이 있을 때만 true 다 — 외부 이미지가 없는 메일은 `images=show` 여도 false(구현하며 정함).
|
||||
쿼리 없이 물으면 설정(아래 `externalImages`)을 따른다: `ask` 는 지금처럼 지우고, `trusted` 는 보낸 사람이 내 주소록(3절)이나 사내 목록(직원 · 공용 주소 — 3절 "사내 목록")에 있으면 보이고,
|
||||
`always` 는 늘 보인다. **스팸메일함의 메일은 설정과 상관없이 지운다**(`images=show` 로만 보인다). `MessageDetail.imagesShown` 이 그 결과다.
|
||||
`trusted` 에서 주소록 · 사내 목록을 읽지 못하면 보이지 않는 쪽으로 다룬다. 머리글 From 은 꾸밀 수 있다(알려진 한계 — 꾸며도 중계라 직원 PC 의 주소는 드러나지 않는다).
|
||||
- 바꾸는 것은 `<img>` 의 `src`(http · https · `//`)뿐이다 — `srcset` 은 지금처럼 지우고, style · 속성의 배경 그림(`url()`)도 지금처럼 지운다(알려진 한계).
|
||||
주소 2,048자 이하만, 한 통에 100개까지(그 뒤는 지운다). 같은 주소는 한 통 안에서 같은 중계 주소를 쓴다(브라우저가 한 번만 받는다). 중계 주소는 `/api/mail-images/<표>` — 표는 직원 · 원래 주소 · 끝나는 시각(1시간)을 서버 비밀로 **암호화한**(인증 암호 AES-GCM —
|
||||
고친 표는 풀리지 않는다) 불투명 글이다. 서명만 하면 표를 풀어 원래 주소(추적 번호가 든다)를 읽을 수 있어 접근 로그 · 주소창에 남는다 — 그래서 암호화한다.
|
||||
키는 서버가 뜰 때 무작위로 만든다(JWT 키와 따로) — 다시 띄우면 옛 표는 404 이고, 메일을 다시 열면 새 표를 받는다. ⚠️ 서버를 여러 대로 늘리면 키를 공유 비밀로 옮긴다.
|
||||
- `GET /api/mail-images/{token}` — 로그인 필요(쿠키), 표의 직원과 같아야 한다. 서버가 바깥 이미지를 받아 그대로 준다(표준 래퍼 없음):
|
||||
- 주소: http · https, 기본 포트(80 · 443)만. 이름을 풀어 **모든 주소가 공인 주소일 때만**, 푼 그 주소로 연결한다(다시 풀지 않는다 — DNS 바꿔치기를 막는다).
|
||||
막는 곳: 사설(10/8 · 172.16/12 · 192.168/16) · 루프백 · 링크 로컬 · CGNAT(100.64/10) · 멀티캐스트 · 예약 · 문서용 대역, IPv6 의 같은 것(::1 · fc00::/7 · fe80::/10 · ff00::/8 · IPv4 대응 · NAT64) —
|
||||
우리 도커 망(`APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET`)도 사설이라 막힌다(메일 엔진 관리 API · DB 를 부르게 만드는 공격).
|
||||
점 없는 이름(도커 서비스 이름 등)은 묻지 않고 막는다. 이름은 c-ares(`dns.Resolver`)로 푼다 — libuv 스레드 풀을 막지 않게(구현하며 정함).
|
||||
- 다시 가기(redirect)는 3번까지, 매번 위 검사. 전체 10초, 받는 크기 5MB(넘으면 끊는다).
|
||||
- 형식은 받은 바이트의 앞부분으로 정한다 — PNG · JPEG · GIF · WebP 만(SVG · 그 밖은 거절). 바깥이 보낸 `Content-Type` 은 믿지 않는다.
|
||||
- 바깥으로는 쿠키 · Referer 없이, 고정한 User-Agent 로 묻는다.
|
||||
- 응답: 받은 바이트 + 판정한 `Content-Type` · `Cache-Control: private, max-age=3600` · `X-Content-Type-Options: nosniff` · `Content-Disposition: inline` · `Content-Security-Policy: default-src 'none'; sandbox`.
|
||||
- 실패는 모두 404 `RES_001` — 까닭을 주지 않는다(표가 틀림 · 지남 · 다른 직원 · 막은 주소 · 받지 못함 · 너무 큼 · 그림이 아님). 화면은 그 자리를 비운다.
|
||||
이 404 는 전역 필터를 거치지 않고 컨트롤러가 직접 쓴다 — 형식 · `traceId` 는 같고, 로그는 debug 만(바깥 탓의 실패가 잦아 warn 이면 알림이 쌓인다 — 구현하며 정함).
|
||||
- 동시에 받는 수는 서버 전체 16개 · 한 직원 6개까지(넘으면 기다린다 — 30초 · 200개까지, 넘으면 404). 기다리는 줄은 직원마다이고, 서버 자리가 나면
|
||||
자기 6개에 막히지 않은 직원 가운데 지금 받는 수가 가장 적은 직원의 맨 앞 요청부터 든다(같으면 먼저 기다린 쪽 — 몇 사람이 서버 자리를 다 차지하지 못하게, 2026-10-29 리뷰 2).
|
||||
자리는 응답을 다 보낼(또는 브라우저가 끊을) 때까지 쥔다 — 메모리에 쥐는 그림은 많아야 16 × 5MB(구현하며 정함 — 2026-10-29 리뷰).
|
||||
요청 수 한도는 이 경로만 넉넉하게 — IP 마다 1분 600회(뉴스레터 한 통에 이미지가 수십 개, 사무실 NAT).
|
||||
- 로그: 주소는 남기지 않는다(추적 번호 · 개인 정보가 들어 있다) — 실패 까닭과 바깥 호스트 이름만 debug. 요청 주소를 남기는 로그의 `/api/mail-images/<표>` 는 글자 수만 남긴다(`maskUrlForLog`).
|
||||
- 화면: 본문 틀의 내부 CSP 를 보일 때만 `img-src data: <화면 주소>/api/mail-images/` 로 넓히고(중계 경로만 — `'self'` 면 정화를 빠져나간 CSS `url()` 이 같은 출처의 다른 API 를 부를 수 있다, 2026-10-29 리뷰),
|
||||
클라이언트 정화(DOMPurify)도 `/api/mail-images/` 로 시작하는 주소만 남긴다(그 밖의 바깥 주소는 지금처럼 지운다).
|
||||
답장 · 전달 인용에는 이미지가 실리지 않는다(보내는 정화가 서명 그림 밖의 `img` 를 지운다 — 중계 주소가 바깥으로 나가지 않는다).
|
||||
- **운영 확인**: 백엔드 컨테이너가 바깥 80 · 443 으로 나갈 수 있어야 한다(호스트 방화벽 · 프록시) — 막혀 있으면 이미지 자리가 비어 보인다.
|
||||
막기는 사설 대역에 기대므로 도커 망(`APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET`)은 사설 대역으로 둔다. 서버 자신의 공인 주소로 풀리는 이름은 공인 주소라 막지 않는다 —
|
||||
같은 외부 프록시 뒤에 "내부 주소면 허용"하는 다른 사이트를 두지 않는다(2026-10-29 리뷰, 운영 절차는 docker 스킬).
|
||||
|
||||
### 인쇄 — 9단계
|
||||
|
||||
API 는 새로 없다. 인쇄 화면(새 탭 — 메일 한 통, 디자인 원본 `MailPrint`)은 `GET /api/messages/{id}` 로 그린다 — 읽기 화면에서 외부 이미지를 보이고 있었으면 `images=show`.
|
||||
|
||||
### 보낸 사람 차단 (`/api/mail-blocks`) — 9단계
|
||||
|
||||
```ts
|
||||
interface BlockedSender {
|
||||
address: string // 소문자로 맞춘 주소("kim@spam.example") 또는 도메인("@spam.example" — 그 도메인에서 오는 메일 모두. 하위 도메인은 따로)
|
||||
blockedAt: string // 차단한 시각(UTC)
|
||||
}
|
||||
interface MailBlockList {
|
||||
senders: BlockedSender[] // 최근에 차단한 것부터, 500개까지
|
||||
isActive: boolean // 엔진에서 우리 규칙(필터 · 부재중과 같은 Sieve 스크립트)이 켜져 있는지 — 필터의 isActive 와 같다
|
||||
}
|
||||
```
|
||||
|
||||
- **효과**: 차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다.
|
||||
이미 받은 메일은 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮긴다(`PATCH /api/messages/{id}` 의 `isJunk`).
|
||||
- **검사**: 주소는 발송의 주소와 같은 규칙에 `@` 앞은 dot-atom · 64자까지(따옴표 앞부분은 받지 않는다 — 클라이언트의 `isStrictEmail` 과 같은 폭, 2026-10-29 리뷰),
|
||||
도메인은 `@` + 호스트 이름(점이 하나 이상 · 253자 · IP 주소 아님 · ASCII 만 — 국제화 도메인은 `xn--` 꼴로, `${` 는 받지 않는다).
|
||||
이미 차단한 주소를 다시 `POST` 하면 200 — 엔진에서 규칙이 꺼져 있었으면 같은 원본을 다시 써서 켠다. 그래서 `POST` · `DELETE` 가 성공한 뒤에는 늘 켜져 있다(`isActive: true`).
|
||||
내 주소와 사내 도메인(`MAIL_DOMAIN` 과 그 하위)은 막을 수 없다.
|
||||
여러 사람이 쓰는 메일 서비스(gmail.com · naver.com · daum.net · hanmail.net · kakao.com · nate.com · outlook.com · hotmail.com · live.com · yahoo.com · icloud.com 등 — 목록은 서버 상수)는
|
||||
도메인으로 막을 수 없다(주소로는 된다). 클라이언트도 같은 목록을 먼저 본다(짝 — 한쪽을 고치면 둘 다). 500개를 넘으면 400 `VAL_001`.
|
||||
- **저장 자리**: 필터 · 부재중과 같은 Sieve 스크립트(`arasystem` — 6단계)의 맨 앞. 머리 주석의 원본(JSON)에 `blocked`(주소 · 차단한 시각)를 더하고, 원본 판은 **차단한 주소가 있을 때만 2** 로 쓴다(없으면 지금처럼 1).
|
||||
판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다. 판 2 를 모르는 옛 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 · 차단 저장이 503 `MAIL_001` 로 막힌다
|
||||
(옛 서버가 새 판을 지우지 않게 — 6단계의 규칙). 되돌리기 계획: 판 2 를 읽기만 하는 서버를 먼저 배포할 수 있게 둔다.
|
||||
만든 Sieve(스팸 표시 감싸기 밖 · 필터 · 부재중 앞): `if anyof (address :all :is "from" [주소들], address :domain :is "from" [도메인들]) { fileinto :mailboxid "<스팸메일함 id>" "<스팸메일함 이름>"; stop; }`.
|
||||
비교는 엔진 기본(`i;ascii-casemap` — ASCII 대소문자 무시)이다 — 엔진의 Sieve 확장 목록에 `comparator-i;unicode-casemap` 이 없어 `:comparator` 를 뺐다(구현하며 정함 —
|
||||
주소 앞부분의 ASCII 밖 글자는 대소문자를 가린다, 한계). `fileinto` 의 대체 이름은 실제 스팸메일함 이름이다(비었거나 `${` 가 들어 있으면 "Junk"). 스팸메일함이 없으면 503 `MAIL_001`.
|
||||
실제 배달로 확인했다(2026-10-29, 38가지 — 대소문자를 섞은 주소 · 도메인은 스팸메일함으로, 하위 도메인 · 이름만 같은 다른 도메인 · 차단하지 않은 주소는 손대지 않음,
|
||||
차단이 있어도 사내 직원의 메일은 받은편지함. `SieveScript/validate` 는 모르는 test 이름을 걸러 주지 않아 배달로 본다).
|
||||
- 스크립트 크기 한도(90,000바이트)를 필터와 함께 쓴다 — 넘으면 400 `MAIL_013`(사전 문구가 필터 · 차단 목록을 함께 말한다).
|
||||
- 쓰기는 필터처럼 읽은 상태 그대로일 때만(`ifInState`) · 어긋나면 다시 읽어 세 번까지. 차단은 필터의 `version` 을 묻지 않는다(주소 하나를 더하고 빼는 일이라 그사이 바뀐 필터를 덮지 않는다).
|
||||
- 로그: 주소는 가린다(`maskEmail` — 도메인 차단은 도메인만).
|
||||
- **클라이언트**: 읽기 화면 ⋯ 「보낸 사람 차단」(사내 직원 · 내 주소면 없고, 이미 차단했으면 「보낸 사람 차단 풀기」, 500개면 꺼짐 + 까닭) → 확인 창(이 주소만 · 이 도메인 전체,
|
||||
「이 메일도 스팸메일함으로 옮기기」) → `POST`(+ 옮기기) → 알림 + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 구현하며 정함). 설정 › 메일 › 차단한 주소에서 더하고 푼다.
|
||||
스팸메일함에서 차단한 보낸 사람의 메일을 열면 안내 줄 + [차단 풀기]. 차단 목록은 메일 화면에 들어올 때 받아 둔다.
|
||||
|
||||
### 보내기 · 읽기 설정 (`/api/mail-settings`) — 9단계
|
||||
|
||||
직원마다의 설정을 서버 DB 에 둔다(표 `mail_settings` — 직원 주소마다 한 줄, 없으면 처음 값). 어느 PC 에서나 같다.
|
||||
|
||||
```ts
|
||||
interface MailSettings {
|
||||
undoSendSeconds: 0 | 5 | 10 | 20 | 30 // 보내기 취소 — 0 이면 바로 보낸다. 처음 10
|
||||
externalImages: 'ask' | 'trusted' | 'always' // 외부 이미지 — 늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음 'ask'
|
||||
}
|
||||
```
|
||||
|
||||
- 서버는 발송 때 `undoSendSeconds`, 메일을 읽을 때 `externalImages` 를 본다(직원마다 잠깐 기억해도 된다 — 그 서버에서 바꾸면 바로 맞춘다).
|
||||
서버마다 60초 기억하고, 읽지 못하면(DB 장애) 처음 값(10초 · `ask`)으로 보고 warn 을 남긴다(구현하며 정함).
|
||||
- 화면(설정 › 메일 › 보내기 · 읽기)은 고르는 대로 `PATCH` 하고 "저장했습니다"를 보인다(저장 단추 없음).
|
||||
칸마다 저장은 한 번에 하나다 — 끝났을 때 마지막으로 고른 값이 서버의 답과 다르면 그 값을 다시 보낸다(늦게 온 앞 응답 때문에 화면과 서버가 갈리지 않게 — 2026-10-29 리뷰).
|
||||
"저장했습니다"는 기다리던 저장이 모두 끝나고 마지막이 성공했을 때 보인다. 끝내 실패하면 서버가 마지막으로 확인해 준 값으로 되돌리고 인터셉터가 알린다.
|
||||
|
||||
### 이 단계에서 하지 않는 것
|
||||
|
||||
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
|
||||
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로),
|
||||
여러 개의 서명, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
|
||||
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, 인쇄.
|
||||
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분,
|
||||
예약한 메일의 「지금 보내기」 · 7일 넘는 예약(엔진 한도) · 차단할 때 이미 받은 그 보낸 사람의 메일 모두 옮기기(9단계는 그 메일만).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user