`)
+ expect(html).toContain(``)
+ expect(html).toContain(` `)
+ expect(html).not.toContain('data-mail-quote')
+ })
+
+ it('여러 칸 띄어쓰기 · 줄 앞 공백 — 받는 쪽에서도 보이게 nbsp 로 (보통 공백과 번갈아, 단어 사이 한 칸은 그대로)', () => {
+ const NBSP = ' '
+
+ expect(toMailHtmlFragment(' 1. 일시 : 10월 27일 ')).toBe(
+ `${NBSP} 1. 일시 ${NBSP} ${NBSP}: 10월 27일 `.replace(/ /g, ' '),
+ )
+ // 서식 경계를 넘어 이어진 공백 · 줄바꿈( ) 뒤의 줄 앞 공백
+ expect(toMailHtmlFragment('a b c ')).toBe(
+ 'a b c ',
+ )
+ // 탭은 다음 탭 칸(8칸)까지의 공백 — 줄 앞이면 8칸, "ab" 뒤면 6칸
+ expect(toMailHtmlFragment('\tx ab\ty ')).toBe(
+ ' x ab y ',
+ )
+ expect(toMailHtmlFragment('한 칸 띄어쓰기 ')).toBe('한 칸 띄어쓰기 ')
+ })
+
+ it('텍스트 대체 본문(body)의 공백은 그대로다', () => {
+ // 쓴 글처럼 공백을 그대로 둔 문서 (HTML 을 읽을 때의 공백 줄이기를 끈다)
+ const doc = createDocument(' 1. 일시 : 10월 27일 \tx ', schema, { preserveWhitespace: 'full' })
+
+ expect(docToText(doc)).toBe(' 1. 일시 : 10월 27일\n\tx')
+ })
+
+ it('"원본 메일" 머리 — 칸 테두리 없이 항목 이름은 흐린 글자, 표시(data-*)는 지운다', () => {
+ const html = toMailHtmlFragment(
+ '원본 메일 ' +
+ '',
+ )
+
+ expect(html).toBe(
+ `원본 메일 ` +
+ `` +
+ `제목 | ` +
+ `일정 | ` +
+ ' ',
+ )
+ })
+})
+
+describe('남의 HTML 을 화면 문서로 옮기지 않는다 (붙여 넣기 · 보낼 모양 — 처리기 실행 방지)', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ const HOSTILE = 'a
'
+
+ it.each([
+ ['normalizeIncomingHtml', () => normalizeIncomingHtml(HOSTILE)],
+ ['toMailHtmlFragment', () => toMailHtmlFragment(`${HOSTILE}`)],
+ ['toMailHtml', () => toMailHtml(`${HOSTILE}`)],
+ ])('%s — 떨어진 문서에서만 다루고, 새 노드(p · br)도 화면 문서에서 만들지 않는다', (_name, run) => {
+ const watch = watchAdoptionIntoLiveDocument()
+ const createElement = vi.spyOn(document, 'createElement')
+ try {
+ run()
+ } finally {
+ watch.restore()
+ }
+
+ expect(watch.adopted).toEqual([])
+ // 화면 문서에서 만드는 것은 파싱 그릇인 뿐이다 (그 내용은 떨어진 문서에 만들어진다)
+ expect(new Set(createElement.mock.calls.map(([tag]) => tag))).toEqual(new Set(['template']))
+ })
+
+ it('지켜보는 장치가 고치기 전 방식(떨어진 문서에서 파싱 → 화면 문서의 div 로 옮김)을 잡는다', () => {
+ const watch = watchAdoptionIntoLiveDocument()
+ try {
+ const template = document.createElement('template')
+ template.innerHTML = HOSTILE
+ document.createElement('div').append(template.content)
+ } finally {
+ watch.restore()
+ }
+
+ expect(watch.adopted).toEqual(['append'])
+ })
+
+ it('출력은 그대로다 — 처리기 속성은 이 단계에서 지우지 않는다 (편집기 스키마 · 서버 정화가 지운다)', () => {
+ expect(normalizeIncomingHtml('a ')).toBe('a ')
+ })
+})
+
+describe('normalizeIncomingHtml — 원문 인용 · 붙여 넣기', () => {
+ it('본문 글자는 바꾸지 않는다 — 글 속의 "font-size: 12pt" 는 그대로 (서식 값은 편집기가 style 속성에서만 읽는다)', () => {
+ expect(normalizeIncomingHtml('본문 규격: font-size: 12pt 로 맞춰 주세요 ')).toBe(
+ '본문 규격: font-size: 12pt 로 맞춰 주세요 ',
+ )
+ expect(normalizeIncomingHtml('a')).toBe(
+ 'a',
+ )
+ })
+
+ it('편집기로 읽으면 style 의 pt 글자 크기만 px 로 바뀐다 — 글자는 그대로', () => {
+ const doc = createDocument(
+ normalizeIncomingHtml('글 속의 font-size: 12pt '),
+ schema,
+ )
+
+ expect(docToText(doc)).toBe('글 속의 font-size: 12pt')
+ expect(toMailHtmlFragment(getHTMLFromFragment(doc.content, schema))).toContain(
+ '글 속의 font-size: 12pt',
+ )
+ })
+
+ it('블록 끝의 은 줄을 더하지 않으므로 뺀다 — 빈 줄(
)은 빈 문단 하나가 된다', () => {
+ expect(normalizeIncomingHtml('글
a
')).toBe('글 a
')
+ expect(normalizeIncomingHtml('글 \n ')).toBe(
+ '글\n ',
+ )
+ })
+
+ it(' 만 있는 div · 목록 항목 · 표 칸은 그 자리에 빈 문단을 둔다 (Gmail 의
)', () => {
+ expect(normalizeIncomingHtml('첫 줄
셋째 줄 ')).toBe(
+ '첫 줄 셋째 줄 ',
+ )
+ expect(normalizeIncomingHtml('')).toBe(
+ '',
+ )
+ })
+
+ it('편집기에서 빈 줄이 한 줄이다 — 우리가 보낸 메일(빈 문단에 )을 다시 인용해도 늘지 않는다', () => {
+ const sent = toMailHtmlFragment('위 아래 ')
+ const doc = createDocument(normalizeIncomingHtml(sent), schema)
+
+ expect(docToText(doc)).toBe('위\n\n아래')
+ expect(doc.childCount).toBe(3)
+ })
+})
diff --git a/frontend/src/components/mail/editor/mail-serialize.ts b/frontend/src/components/mail/editor/mail-serialize.ts
new file mode 100644
index 0000000..2924f8a
--- /dev/null
+++ b/frontend/src/components/mail/editor/mail-serialize.ts
@@ -0,0 +1,383 @@
+// 편집기 내용 → 보내는 본문 — 계약 2절 "서식 본문 (bodyHtml)"
+// - bodyHtml: 편집기 HTML 에 받는 쪽 기본 스타일에 기대지 않도록 모양을 인라인 style 로 넣고,
+// 기본 글꼴 · 글자 크기 · 줄 간격을 가진 div 로 감싼다 (편집기 화면과 같은 값).
+// (받는 메일 프로그램은 에 자기 여백을 준다 — 문단마다 margin:0 을 넣어 Enter 한 번이 한 줄로 보이게 한다)
+// 여러 칸 띄어쓰기 · 줄 앞 공백도 받는 쪽에서 그대로 보이게 nbsp 로 바꾼다 (preserveSpaces).
+// - body: 같은 내용의 일반 텍스트 — 목록은 "- " · "1. ", 인용은 "> ", 표는 칸을 " | " 로 잇는다. 공백은 그대로다.
+// 서버는 받은 HTML 을 다시 허용 목록으로 정화한다(sanitize-outgoing-html.ts). 여기서 넣는 style 은 모두 그 목록 안의 값이다
+// (shared/utils/outgoing-html-rules.ts — 서버와 같은 형식, outgoing-html-rules.spec.ts 가 지킨다).
+// HTML 조각은 화면과 떨어진 문서에서만 다룬다 — 남의 HTML 의 처리기가 돌지 않게 (rewriteInert).
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
+
+import { MAIL_QUOTE_LINE_PREFIX } from '@/shared/constants/mail'
+import {
+ MAIL_BODY_FONT_FAMILY,
+ MAIL_BODY_FONT_SIZE_PX,
+ MAIL_BODY_LINE_HEIGHT,
+} from '@/shared/constants/mail-editor'
+import { listStartOf } from '@/shared/utils/outgoing-html-rules'
+
+// ── 텍스트 ───────────────────────────────────────────────
+
+const LIST_BULLET = '- '
+const TABLE_CELL_SEPARATOR = ' | '
+/** 인용 머리 표는 "항목: 값" 으로 읽힌다 */
+const HEADER_SEPARATOR = ': '
+const HORIZONTAL_RULE_TEXT = '---'
+
+function linkHref(node: ProseMirrorNode): string | null {
+ const link = node.marks.find((mark) => mark.type.name === 'link')
+ const href: unknown = link?.attrs.href
+ return typeof href === 'string' && href !== '' ? href : null
+}
+
+/** 글 블록의 글자 — 줄바꿈(hardBreak)은 \n. 링크는 글자와 주소가 다르면 "글자 (주소)" */
+function inlineText(block: ProseMirrorNode): string {
+ let out = ''
+ let runText = ''
+ let runHref: string | null = null
+ const flush = () => {
+ if (runHref && runHref !== runText && runHref !== `mailto:${runText}`) out += ` (${runHref})`
+ runText = ''
+ runHref = null
+ }
+ block.forEach((child) => {
+ const href = child.isText ? linkHref(child) : null
+ if (href !== runHref) flush()
+ runHref = href
+ if (child.isText) {
+ const text = child.text ?? ''
+ out += text
+ if (href) runText += text
+ } else if (child.type.name === 'hardBreak') {
+ out += '\n'
+ } else {
+ out += child.textContent
+ }
+ })
+ flush()
+ return out
+}
+
+function isQuoteHeaderTable(node: ProseMirrorNode): boolean {
+ return node.type.name === 'table' && Boolean(node.attrs.quoteHeader)
+}
+
+function childLines(node: ProseMirrorNode): string[] {
+ const lines: string[] = []
+ node.forEach((child) => {
+ lines.push(...blockLines(child))
+ // 인용 머리 표와 원문 사이를 한 줄 띄운다 (화면의 12px 간격)
+ if (isQuoteHeaderTable(child)) lines.push('')
+ })
+ return lines
+}
+
+function listLines(list: ProseMirrorNode, marker: (index: number) => string): string[] {
+ const lines: string[] = []
+ list.forEach((item, _offset, index) => {
+ const prefix = marker(index)
+ const continuation = ' '.repeat(prefix.length)
+ childLines(item).forEach((line, lineIndex) => {
+ lines.push(`${lineIndex === 0 ? prefix : continuation}${line}`)
+ })
+ })
+ return lines
+}
+
+function tableLines(table: ProseMirrorNode): string[] {
+ const separator = isQuoteHeaderTable(table) ? HEADER_SEPARATOR : TABLE_CELL_SEPARATOR
+ const lines: string[] = []
+ table.forEach((row) => {
+ const cells: string[] = []
+ row.forEach((cell) => cells.push(childLines(cell).join(' ').trim()))
+ lines.push(cells.join(separator))
+ })
+ return lines
+}
+
+/** 블록 하나의 텍스트 줄 */
+export function blockLines(node: ProseMirrorNode): string[] {
+ switch (node.type.name) {
+ case 'paragraph':
+ return inlineText(node).split('\n')
+ case 'blockquote':
+ return childLines(node).map((line) =>
+ line === '' ? MAIL_QUOTE_LINE_PREFIX.trimEnd() : `${MAIL_QUOTE_LINE_PREFIX}${line}`,
+ )
+ case 'bulletList':
+ return listLines(node, () => LIST_BULLET)
+ case 'orderedList': {
+ // 서식 본문과 같은 시작 번호 — 서버가 남기지 않는 값이면 둘 다 1 부터 (mail-format-extensions.ts)
+ const start = listStartOf(node.attrs.start)
+ return listLines(node, (index) => `${start + index}. `)
+ }
+ case 'table':
+ return tableLines(node)
+ case 'horizontalRule':
+ return [HORIZONTAL_RULE_TEXT]
+ default:
+ return node.isTextblock ? inlineText(node).split('\n') : childLines(node)
+ }
+}
+
+/** 편집기 문서 → 일반 텍스트 본문 (끝의 빈 줄은 뺀다) */
+export function docToText(doc: ProseMirrorNode): string {
+ return childLines(doc).join('\n').replace(/\s+$/, '')
+}
+
+// ── HTML ─────────────────────────────────────────────────
+
+/** 받는 쪽에서도 편집기와 같은 모양이 되도록 넣는 인라인 style (디자인 README "답장 · 전달의 원문 인용" 값) */
+const QUOTE_BAR = 'padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363'
+const CELL_BORDER = '1px solid #D0D5DD'
+
+export const MAIL_HTML_STYLES = {
+ paragraph: 'margin: 0',
+ list: 'margin: 0; padding-left: 24px',
+ quote: `margin: 0; ${QUOTE_BAR}`,
+ /** 원문 인용 — 내 글과 24 띄운다 */
+ mailQuote: `margin: 24px 0 0; ${QUOTE_BAR}`,
+ /** "원본 메일" — 문단 여백(margin: 0)은 모든 문단과 같이 붙는다 */
+ quoteHeading: 'font-size: 12.5px',
+ quoteHeaderTable: 'margin: 0 0 12px; border-collapse: collapse; font-size: 12.5px; line-height: 1.6',
+ quoteHeaderLabel: 'padding: 0 12px 0 0; color: #667085; vertical-align: top; white-space: nowrap',
+ quoteHeaderValue: 'padding: 0; vertical-align: top',
+ table: 'border-collapse: collapse',
+ cell: `border: ${CELL_BORDER}; padding: 4px 8px; vertical-align: top`,
+ rule: `margin: 12px 0; border: 0; border-top: ${CELL_BORDER}`,
+} as const
+
+/** 기본 style 을 앞에 둔다 — 편집기가 넣은 정렬·들여쓰기(margin-left)가 뒤에서 이긴다 */
+function prependStyle(element: Element, style: string) {
+ const existing = element.getAttribute('style')?.trim()
+ element.setAttribute('style', existing ? `${style}; ${existing}` : style)
+}
+
+function styleAll(root: ParentNode, selector: string, style: string) {
+ root.querySelectorAll(selector).forEach((element) => prependStyle(element, style))
+}
+
+function isEmptyParagraph(element: Element): boolean {
+ return element.tagName === 'P' && element.textContent === '' && element.querySelector('br') === null
+}
+
+/**
+ * HTML 조각을 화면과 떨어진 문서에서 고치고 다시 문자열로 만든다 — 보안상 이 함수로만 조각을 다룬다.
+ * 의 내용은 "템플릿 내용 문서"(브라우징 맥락이 없다)에 만들어져 그림을 내려받지 않고 처리기(onerror 등)도 돌지 않는다.
+ * 이 노드를 화면 문서의 요소로 옮기면(입양) 그 순간 가 내려받기를 시작해 처리기가 돈다
+ * (2026-10-27 실측 — 붙여 넣은 남의 HTML 이 여기를 지난다. 운영은 CSP 가 한 번 더 막지만 그것에만 기대지 않는다).
+ * 그래서 결과는 template.innerHTML 로 직렬화하고, 새로 만드는 노드도 같은 떨어진 문서(inert)에서 만든다.
+ */
+function rewriteInert(html: string, rewrite: (root: DocumentFragment, inert: Document) => void): string {
+ const template = document.createElement('template')
+ template.innerHTML = html
+ const root = template.content
+ // 조각에는 늘 주인 문서가 있다 — 없다면(일어나지 않는다) 새 떨어진 문서를 쓴다. 화면 문서로는 돌아가지 않는다
+ rewrite(root, root.ownerDocument ?? document.implementation.createHTMLDocument(''))
+ return template.innerHTML
+}
+
+/** 편집기 HTML 조각에 받는 쪽 모양을 입힌다 (감싸는 div 없이) */
+export function toMailHtmlFragment(editorHtml: string): string {
+ return rewriteInert(editorHtml, applyMailStyles)
+}
+
+function applyMailStyles(root: DocumentFragment, inert: Document) {
+ // 원문 인용 머리 — 표시를 읽고 지운다 (서버도 data-* 를 지운다)
+ const headerTables = new Set(root.querySelectorAll('table[data-quote-header]'))
+ headerTables.forEach((table) => {
+ prependStyle(table, MAIL_HTML_STYLES.quoteHeaderTable)
+ table.querySelectorAll('tr').forEach((row) => {
+ const [label, value] = Array.from(row.children)
+ if (label) prependStyle(label, MAIL_HTML_STYLES.quoteHeaderLabel)
+ if (value) prependStyle(value, MAIL_HTML_STYLES.quoteHeaderValue)
+ })
+ table.removeAttribute('data-quote-header')
+ })
+ root.querySelectorAll('p[data-quote-header]').forEach((heading) => {
+ prependStyle(heading, MAIL_HTML_STYLES.quoteHeading)
+ heading.removeAttribute('data-quote-header')
+ })
+ root.querySelectorAll('blockquote').forEach((quote) => {
+ const isMailQuote = quote.hasAttribute('data-mail-quote')
+ prependStyle(quote, isMailQuote ? MAIL_HTML_STYLES.mailQuote : MAIL_HTML_STYLES.quote)
+ quote.removeAttribute('data-mail-quote')
+ })
+
+ // 표 — 인용 머리 표는 위에서 이미 꾸몄다 (칸 테두리 없음)
+ root.querySelectorAll('table').forEach((table) => {
+ if (headerTables.has(table)) return
+ prependStyle(table, MAIL_HTML_STYLES.table)
+ table.querySelectorAll('td, th').forEach((cell) => prependStyle(cell, MAIL_HTML_STYLES.cell))
+ })
+
+ styleAll(root, 'ul, ol', MAIL_HTML_STYLES.list)
+ styleAll(root, 'hr', MAIL_HTML_STYLES.rule)
+ root.querySelectorAll('p').forEach((paragraph) => {
+ // 빈 문단은 높이가 0 이 되기 쉽다 — 줄바꿈을 넣어 한 줄 높이를 지킨다
+ if (isEmptyParagraph(paragraph)) paragraph.append(inert.createElement('br'))
+ prependStyle(paragraph, MAIL_HTML_STYLES.paragraph)
+ preserveSpaces(paragraph, inert)
+ })
+}
+
+// ── 공백 ─────────────────────────────────────────────────
+
+const NBSP = ' '
+/** 탭이 닿는 칸 — 편집 영역의 탭 간격(브라우저 기본 tab-size 8)과 같다. 편집 영역 CSS 에서 바꾸지 않는다 */
+const TAB_STOP = 8
+
+/**
+ * 여러 칸 띄어쓰기 · 줄 앞 공백(탭 포함)이 받는 쪽에서도 보이게 한다.
+ * 편집기는 공백을 그대로 보이지만(white-space: break-spaces), 받는 쪽은 이어진 공백을 하나로 줄이고 줄 앞 공백을 지운다
+ * — 서버는 white-space 를 normal · nowrap 만 남겨 pre-wrap 으로 지킬 수 없다.
+ * 그래서 줄 앞 공백과, 공백 바로 뒤의 공백을 nbsp 로 바꾼다 — 보통 공백과 번갈아 두어 긴 공백에서도 줄이 바뀔 수 있다.
+ * 탭은 다음 탭 칸(8칸)까지의 공백으로 본다. 단어 사이 공백 한 칸은 그대로다.
+ * 문단 안의 글을 차례로 본다 — 서식(span · strong 등)의 경계를 넘어 이어진 공백도 하나로 본다. 뒤는 새 줄이다.
+ * 텍스트 대체 본문(body)은 바꾸지 않는다 (docToText).
+ */
+function preserveSpaces(paragraph: Element, inert: Document) {
+ const walker = inert.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT)
+ let atLineStart = true
+ let afterSpace = false
+ let column = 0
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ if ((node as Element).tagName === 'BR') {
+ atLineStart = true
+ afterSpace = false
+ column = 0
+ }
+ continue
+ }
+ const text = node as Text
+ let out = ''
+ const space = () => {
+ // 줄 앞 · 공백 바로 뒤의 공백은 받는 쪽이 지우거나 합친다 — nbsp 는 줄지 않는다
+ const keep = atLineStart || afterSpace
+ out += keep ? NBSP : ' '
+ afterSpace = !keep
+ atLineStart = false
+ column += 1
+ }
+ for (const char of text.data) {
+ if (char === ' ') {
+ space()
+ } else if (char === '\t') {
+ const width = TAB_STOP - (column % TAB_STOP)
+ for (let step = 0; step < width; step += 1) space()
+ } else {
+ out += char
+ atLineStart = false
+ afterSpace = false
+ column += 1
+ }
+ }
+ if (out !== text.data) text.data = out
+ }
+}
+
+/** 끝에 남은 빈 문단(편집기가 늘 마지막에 두는 줄)은 보내지 않는다 */
+function trimTrailingEmptyParagraphs(editorHtml: string): string {
+ return editorHtml.replace(/(?:]*)?><\/p>)+$/, '')
+}
+
+/** 보내는 서식 본문 — 기본 글꼴 · 글자 크기 · 줄 간격 div 로 감싼다 (계약 2절 "서식 본문", 편집기 화면과 같은 값) */
+export function toMailHtml(editorHtml: string): string {
+ const inner = toMailHtmlFragment(trimTrailingEmptyParagraphs(editorHtml))
+ const wrapper = [
+ `font-family: ${MAIL_BODY_FONT_FAMILY}`,
+ `font-size: ${MAIL_BODY_FONT_SIZE_PX}px`,
+ `line-height: ${MAIL_BODY_LINE_HEIGHT}`,
+ ].join('; ')
+ return ` ${inner} `
+}
+
+// ── 들어오는 HTML (원문 인용 · 붙여 넣기) ───────────────────────
+
+/** 줄 단위 블록 — 이 안의 맨 끝 은 화면에 줄을 더하지 않는다 */
+const LINE_BLOCKS = new Set([
+ 'P',
+ 'DIV',
+ 'LI',
+ 'TD',
+ 'TH',
+ 'BLOCKQUOTE',
+ 'H1',
+ 'H2',
+ 'H3',
+ 'H4',
+ 'H5',
+ 'H6',
+ 'PRE',
+ 'ADDRESS',
+ 'ARTICLE',
+ 'ASIDE',
+ 'SECTION',
+ 'HEADER',
+ 'FOOTER',
+ 'CENTER',
+ 'DD',
+ 'DT',
+ 'BODY',
+])
+/** 글자가 없어도 내용이 있는 요소 — 이것이 있는 블록은 빈 줄이 아니다 */
+const NON_TEXT_CONTENT = 'img, hr, table, iframe, video, audio, svg, canvas, object, embed, input'
+
+function isSignificant(node: Node): boolean {
+ if (node.nodeType === Node.ELEMENT_NODE) return true
+ return node.nodeType === Node.TEXT_NODE && (node.textContent ?? '').trim() !== ''
+}
+
+function lineBlockOf(node: Node, root: Node): Element | null {
+ let current = node.parentNode
+ while (current && current !== root) {
+ if (current instanceof Element && LINE_BLOCKS.has(current.tagName)) return current
+ current = current.parentNode
+ }
+ return null
+}
+
+/** 블록 맨 끝의 인가 — 그 뒤로 블록 끝까지 글자·요소가 없다 */
+function isTrailingBreak(br: Element, block: Node): boolean {
+ let node: Node | null = br
+ while (node && node !== block) {
+ for (let next = node.nextSibling; next; next = next.nextSibling) {
+ if (isSignificant(next)) return false
+ }
+ node = node.parentNode
+ }
+ return true
+}
+
+/**
+ * 블록 맨 끝의 을 편집기 모양으로 옮긴다.
+ * HTML 에서는 블록 끝의 이 줄을 더하지 않지만("글
" 은 한 줄, "
" 는 빈 줄 하나),
+ * 편집기는 이것을 줄바꿈으로 읽어 빈 줄이 두 줄이 된다. 그래서
+ * - 글이 있는 블록의 끝 은 뺀다
+ * - 만 있는 빈 블록은 빈 문단이 되게 한다 (p 는 만 빼고, div · li · 칸 등은 그 자리에 빈 )
+ */
+function normalizeTrailingBreaks(root: DocumentFragment, inert: Document) {
+ const trailing = Array.from(root.querySelectorAll('br')).flatMap((br) => {
+ const block = lineBlockOf(br, root)
+ return isTrailingBreak(br, block ?? root) ? [{ br, block }] : []
+ })
+ for (const { br, block } of trailing) {
+ const isEmptyLine =
+ block !== null && (block.textContent ?? '').trim() === '' && block.querySelector(NON_TEXT_CONTENT) === null
+ if (isEmptyLine && block.tagName !== 'P') br.replaceWith(inert.createElement('p'))
+ else br.remove()
+ }
+}
+
+/**
+ * 편집기로 들어오는 HTML(원문 인용 · 붙여 넣기)을 편집기 모양에 맞춘다.
+ * 다른 사이트에서 복사한 HTML 이 그대로 들어온다 — 화면 문서로 옮기지 않고 떨어진 문서에서만 다룬다 (rewriteInert).
+ * - 블록 끝 — 빈 줄이 두 줄로 늘지 않게 (normalizeTrailingBreaks)
+ * 서식 값(색 · 글꼴 · 글자 크기 — pt 는 px 로)은 편집기가 읽을 때 style 속성에서만 다룬다 (mail-format-extensions.ts).
+ * 여기서 HTML 문자열을 통째로 바꾸지 않는다 — 본문 글자 "font-size: 12pt" 까지 바뀐다.
+ */
+export function normalizeIncomingHtml(html: string): string {
+ return rewriteInert(html, normalizeTrailingBreaks)
+}
diff --git a/frontend/src/components/mail/editor/toolbar-menus.ts b/frontend/src/components/mail/editor/toolbar-menus.ts
new file mode 100644
index 0000000..4046db4
--- /dev/null
+++ b/frontend/src/components/mail/editor/toolbar-menus.ts
@@ -0,0 +1,265 @@
+// 툴바 단추 · 메뉴 항목 만들기 — 아이콘 단추 · 글꼴 · 글자 크기 · 더보기 (디자인 README "본문 서식 편집기", 원본 ComposeEditor "더보기 메뉴")
+// 더보기: 좁은 폭에서 옮겨 온 묶음이 맨 위에 툴바 순서대로 오고, 그 아래에 늘 있는 서식이 온다.
+// 취소선 | 왼쪽 · 가운데 · 오른쪽 정렬 | 내어쓰기 · 들여쓰기 | 인용 · 표 넣기 › · 가로줄 | 서식 지우기
+import type { ChainedCommands } from '@tiptap/core'
+
+import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
+import type { FontStatus, SizeStatus, ToolbarStatus } from '@/components/mail/editor/editor-state'
+import type { IconName } from '@/shared/constants/icons'
+import {
+ EDITOR_FONTS_EN,
+ EDITOR_FONTS_KO,
+ EDITOR_FONT_SIZES,
+ EDITOR_SHORTCUTS,
+ MAIL_BODY_FONT_SIZE_PX,
+ type EditorFont,
+ type EditorShortcut,
+} from '@/shared/constants/mail-editor'
+import type { ToolbarGroup } from '@/shared/utils/editor-toolbar-layout'
+
+// ── 아이콘 단추 (툴바에 늘 보이는 것) ─────────────────────
+
+export type IconToolId = 'undo' | 'redo' | 'bold' | 'italic' | 'underline' | 'bulletList' | 'orderedList'
+
+/** 아이콘 하나짜리 단추 — 누르면 바로 서식을 바꾼다 */
+export interface IconTool {
+ id: IconToolId
+ label: string
+ icon: IconName
+ shortcut: EditorShortcut
+ /** 켜고 끄는 서식이면 지금 상태 (아니면 null) */
+ pressed: boolean | null
+ disabled: boolean
+ run: (chain: ChainedCommands) => ChainedCommands
+}
+
+/** 실행 취소 · 다시 실행 | 굵게 · 기울임 · 밑줄 | 글머리 · 번호 목록 */
+export function iconToolGroups(status: ToolbarStatus): Record<'history' | 'marks' | 'list', IconTool[]> {
+ const toggle = (id: IconToolId, label: string, icon: IconName, pressed: boolean, run: IconTool['run']): IconTool => ({
+ id,
+ label,
+ icon,
+ shortcut: EDITOR_SHORTCUTS[id],
+ pressed,
+ disabled: false,
+ run,
+ })
+ return {
+ history: [
+ {
+ id: 'undo',
+ label: '실행 취소',
+ icon: 'undo',
+ shortcut: EDITOR_SHORTCUTS.undo,
+ pressed: null,
+ disabled: !status.canUndo,
+ run: (chain) => chain.undo(),
+ },
+ {
+ id: 'redo',
+ label: '다시 실행',
+ icon: 'redo',
+ shortcut: EDITOR_SHORTCUTS.redo,
+ pressed: null,
+ disabled: !status.canRedo,
+ run: (chain) => chain.redo(),
+ },
+ ],
+ marks: [
+ toggle('bold', '굵게', 'bold', status.bold, (chain) => chain.toggleBold()),
+ toggle('italic', '기울임', 'italic', status.italic, (chain) => chain.toggleItalic()),
+ toggle('underline', '밑줄', 'underline', status.underline, (chain) => chain.toggleUnderline()),
+ ],
+ list: [
+ toggle('bulletList', '글머리 목록', 'list-bullet', status.bulletList, (chain) => chain.toggleBulletList()),
+ toggle('orderedList', '번호 목록', 'list-ordered', status.orderedList, (chain) => chain.toggleOrderedList()),
+ ],
+ }
+}
+
+// ── 메뉴 ──────────────────────────────────────────────
+
+/** 기본값 표시 — 글꼴 "맑은 고딕", 글자 크기 14 옆 */
+const BASE_BADGE = '기본'
+
+export function fontMenuItems(current: FontStatus): EditorMenuItem[] {
+ const entry = (font: EditorFont): EditorMenuItem => ({
+ id: font.id,
+ role: 'menuitemradio',
+ label: font.label,
+ preview: `font-${font.id}`,
+ checked: !current.mixed && current.font?.id === font.id,
+ ...(font.isBase ? { badge: BASE_BADGE } : {}),
+ })
+ return [...EDITOR_FONTS_KO.map(entry), menuSeparator('font-latin'), ...EDITOR_FONTS_EN.map(entry)]
+}
+
+export function sizeMenuItems(current: SizeStatus): EditorMenuItem[] {
+ return EDITOR_FONT_SIZES.map((size) => ({
+ id: String(size),
+ role: 'menuitemradio',
+ label: String(size),
+ preview: `size-${size}`,
+ checked: !current.mixed && current.px === size,
+ ...(size === MAIL_BODY_FONT_SIZE_PX ? { badge: BASE_BADGE } : {}),
+ }))
+}
+
+/** 더보기에서 한 단계 더 여는 항목 — 그 팝오버를 더보기 단추 아래에 연다 */
+export const MORE_SUBMENUS = ['font', 'size', 'textColor', 'backgroundColor', 'link', 'table'] as const
+export type MoreSubmenu = (typeof MORE_SUBMENUS)[number]
+
+export function isMoreSubmenu(id: string): id is MoreSubmenu {
+ return (MORE_SUBMENUS as readonly string[]).includes(id)
+}
+
+/** 더보기에서 바로 실행하는 서식 */
+export const MORE_COMMANDS: Readonly ChainedCommands>> = {
+ undo: (chain) => chain.undo(),
+ redo: (chain) => chain.redo(),
+ bulletList: (chain) => chain.toggleBulletList(),
+ orderedList: (chain) => chain.toggleOrderedList(),
+ strike: (chain) => chain.toggleStrike(),
+ // 왼쪽은 기본 정렬이라 정렬 지정을 지운다
+ alignLeft: (chain) => chain.unsetTextAlign(),
+ alignCenter: (chain) => chain.setTextAlign('center'),
+ alignRight: (chain) => chain.setTextAlign('right'),
+ outdent: (chain) => chain.outdentBlock(),
+ indent: (chain) => chain.indentBlock(),
+ blockquote: (chain) => chain.toggleBlockquote(),
+ horizontalRule: (chain) => chain.setHorizontalRule(),
+ clearFormatting: (chain) => chain.clearFormatting(),
+}
+
+/** 좁은 폭에서 더보기로 옮겨 온 묶음의 항목 — 툴바 순서대로 */
+function movedItems(status: ToolbarStatus, hidden: ReadonlySet): EditorMenuItem[] {
+ const items: EditorMenuItem[] = []
+ if (hidden.has('history')) {
+ items.push(
+ { id: 'undo', label: '실행 취소', icon: 'undo', hint: EDITOR_SHORTCUTS.undo.label, disabled: !status.canUndo },
+ { id: 'redo', label: '다시 실행', icon: 'redo', hint: EDITOR_SHORTCUTS.redo.label, disabled: !status.canRedo },
+ menuSeparator('after-history'),
+ )
+ }
+ if (hidden.has('font')) {
+ items.push(
+ { id: 'font', label: '글꼴', icon: 'type', hint: status.font.label, opens: 'menu' },
+ { id: 'size', label: '글자 크기', icon: 'font-size', hint: status.size.label, opens: 'menu' },
+ menuSeparator('after-font'),
+ )
+ }
+ if (hidden.has('color')) {
+ items.push(
+ {
+ id: 'textColor',
+ label: '글자 색',
+ colorGlyph: { kind: 'text', color: status.textColor.value },
+ hint: status.textColor.name,
+ opens: 'dialog',
+ },
+ {
+ id: 'backgroundColor',
+ label: '배경 색',
+ colorGlyph: { kind: 'background', color: status.backgroundColor.value },
+ hint: status.backgroundColor.name,
+ opens: 'dialog',
+ },
+ menuSeparator('after-color'),
+ )
+ }
+ if (hidden.has('list')) {
+ items.push(
+ {
+ id: 'bulletList',
+ role: 'menuitemcheckbox',
+ label: '글머리 목록',
+ icon: 'list-bullet',
+ hint: EDITOR_SHORTCUTS.bulletList.label,
+ checked: status.bulletList,
+ },
+ {
+ id: 'orderedList',
+ role: 'menuitemcheckbox',
+ label: '번호 목록',
+ icon: 'list-ordered',
+ hint: EDITOR_SHORTCUTS.orderedList.label,
+ checked: status.orderedList,
+ },
+ menuSeparator('after-list'),
+ )
+ }
+ if (hidden.has('link')) {
+ items.push(
+ {
+ id: 'link',
+ label: status.link ? '링크 고치기' : '링크 넣기',
+ icon: 'link',
+ hint: EDITOR_SHORTCUTS.link.label,
+ opens: 'dialog',
+ },
+ menuSeparator('after-link'),
+ )
+ }
+ return items
+}
+
+export function moreMenuItems(status: ToolbarStatus, hidden: ReadonlySet): EditorMenuItem[] {
+ return [
+ ...movedItems(status, hidden),
+ {
+ id: 'strike',
+ role: 'menuitemcheckbox',
+ label: '취소선',
+ icon: 'strikethrough',
+ hint: EDITOR_SHORTCUTS.strike.label,
+ checked: status.strike,
+ },
+ menuSeparator('after-strike'),
+ {
+ id: 'alignLeft',
+ role: 'menuitemradio',
+ label: '왼쪽 정렬',
+ icon: 'align-left',
+ hint: EDITOR_SHORTCUTS.alignLeft.label,
+ checked: status.align === 'left',
+ },
+ {
+ id: 'alignCenter',
+ role: 'menuitemradio',
+ label: '가운데 정렬',
+ icon: 'align-center',
+ hint: EDITOR_SHORTCUTS.alignCenter.label,
+ checked: status.align === 'center',
+ },
+ {
+ id: 'alignRight',
+ role: 'menuitemradio',
+ label: '오른쪽 정렬',
+ icon: 'align-right',
+ hint: EDITOR_SHORTCUTS.alignRight.label,
+ checked: status.align === 'right',
+ },
+ menuSeparator('after-align'),
+ { id: 'outdent', label: '내어쓰기', icon: 'outdent', hint: EDITOR_SHORTCUTS.outdent.label },
+ { id: 'indent', label: '들여쓰기', icon: 'indent', hint: EDITOR_SHORTCUTS.indent.label },
+ menuSeparator('after-indent'),
+ {
+ id: 'blockquote',
+ role: 'menuitemcheckbox',
+ label: '인용',
+ icon: 'quote',
+ hint: EDITOR_SHORTCUTS.blockquote.label,
+ checked: status.blockquote,
+ },
+ // 표 안에는 표를 넣지 않는다
+ { id: 'table', label: '표 넣기', icon: 'table', opens: 'dialog', disabled: status.inTable },
+ { id: 'horizontalRule', label: '가로줄', icon: 'horizontal-rule' },
+ menuSeparator('after-insert'),
+ {
+ id: 'clearFormatting',
+ label: '서식 지우기',
+ icon: 'clear-format',
+ hint: EDITOR_SHORTCUTS.clearFormatting.label,
+ },
+ ]
+}
diff --git a/frontend/src/components/mail/editor/useToolbarCollapse.ts b/frontend/src/components/mail/editor/useToolbarCollapse.ts
new file mode 100644
index 0000000..e7bace9
--- /dev/null
+++ b/frontend/src/components/mail/editor/useToolbarCollapse.ts
@@ -0,0 +1,75 @@
+// 툴바 좁은 폭 처리 — 툴바의 실제 폭을 재어(ResizeObserver) 정해진 순서로 묶음째 더보기에 옮긴다
+// (디자인 README "좁은 폭" — 고정 px 이 아니라 잰 폭이라 글자 크기 설정이 달라도 같은 순서로 접힌다)
+// 묶음 폭은 [data-group] 요소, 더보기 단추는 [data-tool="more"] 로 잰다. 순서 · 계산은 shared/utils/editor-toolbar-layout.ts
+import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
+
+import { REM_BASE_PX } from '@/shared/constants/icons'
+import {
+ TOOLBAR_GROUPS,
+ collapsedToolbarGroups,
+ type ToolbarGroup,
+} from '@/shared/utils/editor-toolbar-layout'
+
+/** 폭을 잴 때 반올림 차이로 접히지 않게 두는 여유(px) */
+const LAYOUT_TOLERANCE_PX = 0.5
+
+function rootFontPx(): number {
+ return Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || REM_BASE_PX
+}
+
+function sameGroups(a: ReadonlySet, b: ReadonlySet): boolean {
+ return a.size === b.size && [...a].every((group) => b.has(group))
+}
+
+export function useToolbarCollapse(rootRef: Ref) {
+ /** 더보기로 옮긴 묶음 */
+ const hidden = ref>(new Set())
+ /** 묶음 폭(rem) — 숨은 묶음은 마지막으로 잰 값을 쓴다. 글자 크기 설정이 바뀌어도 맞도록 rem 으로 둔다 */
+ const widthRem: Partial> = {}
+ let observer: ResizeObserver | null = null
+
+ function measureWidth(key: ToolbarGroup | 'more', element: Element | null | undefined, remPx: number) {
+ const width = element?.getBoundingClientRect().width ?? 0
+ if (width > 0) widthRem[key] = width / remPx
+ }
+
+ function measure() {
+ const root = rootRef.value
+ if (!root) return
+ const remPx = rootFontPx()
+ const style = getComputedStyle(root)
+ const padding = (Number.parseFloat(style.paddingLeft) || 0) + (Number.parseFloat(style.paddingRight) || 0)
+ const available = root.clientWidth - padding
+ // 화면에 그려지지 않았다(숨김 등) — 잴 수 없으니 그대로 둔다
+ if (!(available > 0)) return
+ for (const group of TOOLBAR_GROUPS) {
+ if (!hidden.value.has(group)) measureWidth(group, root.querySelector(`[data-group="${group}"]`), remPx)
+ }
+ measureWidth('more', root.querySelector('[data-tool="more"]'), remPx)
+
+ const groups = Object.fromEntries(
+ TOOLBAR_GROUPS.map((group) => [group, (widthRem[group] ?? 0) * remPx]),
+ ) as Record
+ const next = collapsedToolbarGroups({
+ available: available + LAYOUT_TOLERANCE_PX,
+ groups,
+ more: (widthRem.more ?? 0) * remPx,
+ gap: Number.parseFloat(style.columnGap) || 0,
+ })
+ if (!sameGroups(next, hidden.value)) hidden.value = next
+ }
+
+ onMounted(() => {
+ measure()
+ if (typeof ResizeObserver === 'undefined' || !rootRef.value) return
+ observer = new ResizeObserver(() => measure())
+ observer.observe(rootRef.value)
+ })
+
+ onBeforeUnmount(() => {
+ observer?.disconnect()
+ observer = null
+ })
+
+ return { hidden }
+}
diff --git a/frontend/src/composables/useAttachmentUploads.spec.ts b/frontend/src/composables/useAttachmentUploads.spec.ts
index 358a959..b432c05 100644
--- a/frontend/src/composables/useAttachmentUploads.spec.ts
+++ b/frontend/src/composables/useAttachmentUploads.spec.ts
@@ -71,10 +71,13 @@ describe('useAttachmentUploads', () => {
const serverLimit = 5 * BYTES_PER_MB
const { uploads, stop } = withUploads({ maxBytes: () => serverLimit })
- uploads.addFiles([file('작은.pdf', serverLimit), file('큰.pdf', serverLimit + 1)])
+ const small = file('작은.pdf', serverLimit)
+ const accepted = uploads.addFiles([small, file('큰.pdf', serverLimit + 1)])
expect(uploads.items.value.map((item) => item.status)).toEqual(['uploading', 'failed'])
expect(uploads.items.value[1]?.errorMessage).toBe(ERROR_CODE_MESSAGES.FILE_002)
+ // 올리기 시작한 파일만 돌려준다 — 화면은 이것으로 "이미지는 첨부로" 알림을 띄울지 정한다
+ expect(accepted).toEqual([small])
stop()
})
diff --git a/frontend/src/composables/useAttachmentUploads.ts b/frontend/src/composables/useAttachmentUploads.ts
index c949044..fd7143f 100644
--- a/frontend/src/composables/useAttachmentUploads.ts
+++ b/frontend/src/composables/useAttachmentUploads.ts
@@ -129,8 +129,10 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
/**
* 새 파일을 올린다 — 사전 검사(개수·형식·파일당 한도·합계 한도)에 걸리면 올리지 않고 실패로 표시한다.
* 여러 개를 한 번에 고르면 앞에서부터 붙이며 개수·합계를 센다 (넘는 파일만 빠진다).
+ * @returns 사전 검사를 통과해 올리기 시작한 파일 — 화면이 알림을 띄울지 정하는 데 쓴다
*/
- function addFiles(files: Iterable) {
+ function addFiles(files: Iterable): File[] {
+ const accepted: File[] = []
for (const file of files) {
const key = takeKey()
const rejected = precheckAttachment(file, limitsFor('upload'), attached.value)
@@ -148,8 +150,12 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
missing: false,
},
]
- if (!rejected) void upload(file, key)
+ if (!rejected) {
+ accepted.push(file)
+ void upload(file, key)
+ }
}
+ return accepted
}
/**
diff --git a/frontend/src/composables/useWindowFileDrop.ts b/frontend/src/composables/useWindowFileDrop.ts
new file mode 100644
index 0000000..4590a40
--- /dev/null
+++ b/frontend/src/composables/useWindowFileDrop.ts
@@ -0,0 +1,58 @@
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+
+import { isFileDrag } from '@/shared/utils/editor-files'
+
+/**
+ * 화면 어디에 놓아도 파일을 받는다 — 이 화면이 떠 있는 동안 window 에서 파일 끌기를 받는다
+ * (디자인 README "본문 이미지 → 첨부 파일": 쓰기 화면 어디에 놓아도 첨부로 올린다).
+ * 받지 않은 곳에 놓으면 브라우저가 그 파일을 열어(페이지 이동) 쓰던 내용을 잃는다.
+ * - 파일 끌기의 dragover · drop 기본 동작을 막는다 — 글을 끄는 것은 그대로 둔다
+ * - 다른 곳(본문 편집기)이 이미 처리한 놓기(defaultPrevented)는 다시 넘기지 않는다
+ * - dragging: 파일을 끌어 창 위에 있는 동안 true — 들어온 요소 수를 세어 자식 사이를 지날 때 깜빡이지 않고,
+ * 들어온 만큼 나가면(창 밖으로 · Esc 로 그만둠 — 브라우저가 dragleave 를 보낸다) false
+ */
+export function useWindowFileDrop(onFiles: (files: File[]) => void) {
+ const depth = ref(0)
+ const dragging = computed(() => depth.value > 0)
+
+ function onDragEnter(event: DragEvent) {
+ if (!isFileDrag(event)) return
+ event.preventDefault()
+ depth.value += 1
+ }
+
+ function onDragOver(event: DragEvent) {
+ if (!isFileDrag(event)) return
+ event.preventDefault()
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
+ }
+
+ function onDragLeave(event: DragEvent) {
+ if (!isFileDrag(event)) return
+ depth.value = Math.max(0, depth.value - 1)
+ }
+
+ function onDrop(event: DragEvent) {
+ depth.value = 0
+ if (event.defaultPrevented || !isFileDrag(event)) return
+ event.preventDefault()
+ onFiles(Array.from(event.dataTransfer?.files ?? []))
+ }
+
+ const listeners = [
+ ['dragenter', onDragEnter],
+ ['dragover', onDragOver],
+ ['dragleave', onDragLeave],
+ ['drop', onDrop],
+ ] as const
+
+ onMounted(() => {
+ for (const [type, listener] of listeners) window.addEventListener(type, listener)
+ })
+
+ onBeforeUnmount(() => {
+ for (const [type, listener] of listeners) window.removeEventListener(type, listener)
+ })
+
+ return { dragging }
+}
diff --git a/frontend/src/pages/mail/ComposePage.editor-load.spec.ts b/frontend/src/pages/mail/ComposePage.editor-load.spec.ts
new file mode 100644
index 0000000..e666080
--- /dev/null
+++ b/frontend/src/pages/mail/ComposePage.editor-load.spec.ts
@@ -0,0 +1,193 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount, type VueWrapper } from '@vue/test-utils'
+import { createPinia, setActivePinia, type Pinia } from 'pinia'
+
+import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
+import type { MessageDetail } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { useToastStore } from '@/stores/toast.store'
+import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockHandler } from '@/test-utils/mock-api'
+import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
+import { createTestRouter } from '@/test-utils/router'
+import ComposePage from './ComposePage.vue'
+
+/**
+ * 편집기 청크 내려받기를 마음대로 — 실패시키거나(첫 번째만) 사용자가 놓아 줄 때까지 붙잡는다.
+ * ComposePage 는 mail-editor-handle.ts 의 loadMailEditor 로 청크를 받는다.
+ */
+const loader = vi.hoisted(() => ({
+ mode: 'normal' as 'normal' | 'fail-once' | 'hold',
+ calls: 0,
+ release: null as null | (() => void),
+}))
+
+vi.mock('@/components/mail/editor/mail-editor-handle', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ loadMailEditor: () => {
+ loader.calls += 1
+ if (loader.mode === 'fail-once' && loader.calls === 1) {
+ return Promise.reject(new Error('Failed to fetch dynamically imported module'))
+ }
+ if (loader.mode === 'hold') {
+ return new Promise((resolve) => {
+ loader.release = () => resolve(actual.loadMailEditor())
+ })
+ }
+ return actual.loadMailEditor()
+ },
+ }
+})
+
+const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
+const EDITOR_LOAD_TIMEOUT_MS = 10_000
+
+const ORIGINAL: MessageDetail = {
+ id: 'm1',
+ threadId: 't1',
+ mailboxIds: ['inbox'],
+ from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
+ to: [{ name: '김아라', email: USER.email }],
+ subject: '메일 서버 이전 일정 공유',
+ preview: '',
+ receivedAt: '2026-10-26T01:42:00Z',
+ isRead: true,
+ isFlagged: false,
+ hasAttachment: false,
+ size: 100,
+ cc: [],
+ bcc: [],
+ replyTo: [],
+ sentAt: null,
+ messageId: '',
+ references: [],
+ htmlBody: null,
+ textBody: '김아라님, 안녕하세요.',
+ hasBlockedImages: false,
+ attachments: [],
+}
+
+const api: MockHandler = ({ method, url }) => {
+ if (method === 'GET' && url === '/messages/m1') return ok(ORIGINAL)
+ return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
+}
+
+describe('ComposePage — 서식 편집기 청크', () => {
+ let pinia: Pinia
+ let wrapper: VueWrapper | null = null
+ const loadErrors: unknown[] = []
+
+ beforeAll(installProseMirrorDom)
+ beforeAll(async () => {
+ await import('@/components/mail/editor/MailEditor.vue')
+ }, EDITOR_LOAD_TIMEOUT_MS)
+
+ beforeEach(() => {
+ pinia = createPinia()
+ setActivePinia(pinia)
+ useAuthStore().markAuthenticated(USER)
+ loader.mode = 'normal'
+ loader.calls = 0
+ loader.release = null
+ loadErrors.length = 0
+ })
+
+ afterEach(() => {
+ wrapper?.unmount()
+ wrapper = null
+ useToastStore().clear()
+ restoreApiAdapter()
+ })
+
+ async function mountCompose(path: string): Promise {
+ installMockApi(api)
+ const router = createTestRouter()
+ await router.push(path)
+ await router.isReady()
+ wrapper = mount(ComposePage, {
+ attachTo: document.body,
+ global: {
+ plugins: [pinia, router],
+ // 내려받기 오류는 Vue 가 앱 오류로도 알린다 — 테스트에서는 모아 둔다
+ config: { errorHandler: (error) => loadErrors.push(error) },
+ },
+ })
+ await flushPromises()
+ return wrapper
+ }
+
+ /** 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤 */
+ async function releaseEditor() {
+ await vi.waitFor(() => {
+ if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
+ })
+ loader.release?.()
+ }
+
+ async function editorLoaded(view: VueWrapper) {
+ await vi.waitFor(
+ () => {
+ if (!view.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
+ },
+ { timeout: EDITOR_LOAD_TIMEOUT_MS },
+ )
+ await flushPromises()
+ }
+
+ it('청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도" — 보내기는 막힌다. 다시 시도하면 편집기가 뜬다', async () => {
+ loader.mode = 'fail-once'
+ const view = await mountCompose('/mail/compose')
+
+ await vi.waitFor(() => {
+ if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
+ })
+ const alert = view.get('.compose__editor [role="alert"]')
+ expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
+ expect(view.get('.compose__send').attributes('disabled')).toBeDefined()
+ // 편집기에 넘기던 속성(초안 HTML 등)이 오류 화면에 붙지 않는다
+ expect(alert.attributes('initial-html')).toBeUndefined()
+ expect(loadErrors).toHaveLength(1)
+
+ const retry = alert.findAll('button').find((button) => button.text() === '다시 시도')
+ // 누르면 단추에 초점이 간다 (브라우저처럼)
+ ;(retry?.element as HTMLElement | undefined)?.focus()
+ await retry?.trigger('click')
+ await editorLoaded(view)
+
+ expect(loader.calls).toBe(2)
+ expect(view.find('.compose__editor [role="alert"]').exists()).toBe(false)
+ expect(view.get('.compose__send').attributes('disabled')).toBeUndefined()
+ // 누른 "다시 시도" 단추가 사라졌다 — 초점은 본문으로
+ await vi.waitFor(() => {
+ if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
+ })
+ })
+
+ it('답장 — 청크가 늦게 와도 이미 다른 칸(제목)에 쓰고 있으면 본문으로 초점을 빼앗지 않는다', async () => {
+ loader.mode = 'hold'
+ const view = await mountCompose('/mail/compose?replyTo=m1')
+ const subject = view.get('#compose-subject').element as HTMLInputElement
+ subject.focus()
+
+ await releaseEditor()
+ await editorLoaded(view)
+ await flushPromises()
+
+ expect(document.activeElement).toBe(subject)
+ })
+
+ it('답장 — 아무 칸에도 초점이 없으면 편집기가 준비될 때 본문 맨 위로', async () => {
+ loader.mode = 'hold'
+ const view = await mountCompose('/mail/compose?replyTo=m1')
+ ;(document.activeElement as HTMLElement | null)?.blur()
+
+ await releaseEditor()
+ await editorLoaded(view)
+ await vi.waitFor(() => {
+ if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
+ })
+
+ expect(document.activeElement?.id).toBe('compose-body')
+ })
+})
diff --git a/frontend/src/pages/mail/ComposePage.spec.ts b/frontend/src/pages/mail/ComposePage.spec.ts
index f1ab622..ce750b4 100644
--- a/frontend/src/pages/mail/ComposePage.spec.ts
+++ b/frontend/src/pages/mail/ComposePage.spec.ts
@@ -1,8 +1,14 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
+import {
+ MAIL_BODY_MAX_LENGTH,
+ MAIL_QUOTE_OMITTED_MARK,
+ MAIL_SUBJECT_MAX_LENGTH,
+} from '@/shared/constants/mail'
+import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import type { MessageDetail } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -17,10 +23,13 @@ import {
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
+import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
import { createTestRouter } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
+/** 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다 */
+const EDITOR_LOAD_TIMEOUT_MS = 10_000
let clock = Date.UTC(2026, 9, 26, 6, 0, 0)
const ORIGINAL: MessageDetail = {
@@ -51,6 +60,12 @@ const ORIGINAL: MessageDetail = {
describe('ComposePage', () => {
let pinia: Pinia
+ beforeAll(installProseMirrorDom)
+ // 편집기 모듈을 미리 불러 둔다 — 첫 변환 시간(한꺼번에 돌리면 수 초)이 첫 테스트의 시간 한도(기본 5초)에 잡히지 않게
+ beforeAll(async () => {
+ await import('@/components/mail/editor/MailEditor.vue')
+ }, EDITOR_LOAD_TIMEOUT_MS)
+
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
@@ -60,13 +75,19 @@ describe('ComposePage', () => {
vi.setSystemTime(clock)
})
+ /** 이 테스트에서 띄운 화면 — 쓰기 화면은 window 에 파일 끌기 처리기를 달므로 다음 테스트로 새지 않게 내린다 */
+ const mountedWrappers: VueWrapper[] = []
+
afterEach(() => {
+ for (const wrapper of mountedWrappers.splice(0)) {
+ if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
+ }
useToastStore().clear()
restoreApiAdapter()
vi.useRealTimers()
})
- /** attach: 초점 이동을 확인할 때 문서에 붙인다 (테스트 끝에 wrapper.unmount()) */
+ /** attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다 */
async function mountCompose(path: string, handler: MockHandler, attach = false) {
const requests = installMockApi(handler)
const router = createTestRouter()
@@ -76,10 +97,28 @@ describe('ComposePage', () => {
attachTo: attach ? document.body : undefined,
global: { plugins: [pinia, router] },
})
+ mountedWrappers.push(wrapper)
await flushPromises()
+ await editorLoaded(wrapper)
return { wrapper, router, requests }
}
+ /** 서식 편집기는 지연 로딩한다 — 불러와 준비될 때까지 기다린다 (처음 불러올 때는 변환에 시간이 걸린다) */
+ async function editorLoaded(wrapper: VueWrapper) {
+ await vi.waitFor(
+ () => {
+ if (!wrapper.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
+ },
+ { timeout: EDITOR_LOAD_TIMEOUT_MS },
+ )
+ await flushPromises()
+ }
+
+ /** 본문 편집 영역에 보이는 글 */
+ function bodyText(wrapper: VueWrapper): string {
+ return wrapper.get('#compose-body').text()
+ }
+
function chips(wrapper: VueWrapper, inputId: string): string[] {
const field = wrapper.get(`#${inputId}`).element.closest('.recipient-field')
return Array.from(field?.querySelectorAll('.recipient-chip__label') ?? []).map(
@@ -105,10 +144,25 @@ describe('ComposePage', () => {
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
'Re: 메일 서버 이전 일정 공유',
)
- const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
- expect(body).toContain('──────────')
- expect(body).toContain('이도윤 작성:')
- expect(body).toContain('> 김아라님, 안녕하세요.')
+ // 원문은 "원본 메일" 머리(보낸 사람 · 날짜 · 제목)와 함께 인용 막대 안에 (디자인 README "원문 인용")
+ const quote = wrapper.get('#compose-body blockquote[data-mail-quote]')
+ expect(quote.get('p[data-quote-header]').text()).toBe('원본 메일')
+ expect(quote.get('table[data-quote-header]').text()).toContain('보낸 사람이도윤 ')
+ expect(quote.text()).toContain('김아라님, 안녕하세요.')
+ expect(quote.text()).not.toContain('받는 사람')
+ })
+
+ it('답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다', async () => {
+ const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', replyApi, true)
+ await vi.waitFor(
+ () => {
+ if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
+ },
+ { timeout: EDITOR_LOAD_TIMEOUT_MS },
+ )
+
+ expect(editorOf(wrapper.element).state.selection.from).toBe(1)
+ wrapper.unmount()
})
it('전체 답장(&all=1)은 원래 참조까지 채운다', async () => {
@@ -130,7 +184,11 @@ describe('ComposePage', () => {
cc: [],
bcc: [],
subject: 'Re: 메일 서버 이전 일정 공유',
+ // 서식 본문과 같은 내용의 텍스트 — 인용은 "> " (계약 2절 "서식 본문")
body: expect.stringContaining('> 김아라님, 안녕하세요.'),
+ bodyHtml: expect.stringMatching(
+ /^$/,
+ ),
attachments: [],
inReplyTo: 'm1',
})
@@ -202,10 +260,13 @@ describe('ComposePage', () => {
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
'Fwd: 메일 서버 이전 일정 공유',
)
- const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
- expect(body).toContain('전달된 메일')
- expect(body).toContain('보낸 사람: 이도윤 ')
- expect(body).toContain('김아라님, 안녕하세요.')
+ // 전달 머리에는 받는 사람 · 참조도 있다
+ const header = wrapper.get('#compose-body table[data-quote-header]').text()
+ expect(bodyText(wrapper)).toContain('원본 메일')
+ expect(header).toContain('보낸 사람이도윤 ')
+ expect(header).toContain('받는 사람김아라 ')
+ expect(header).toContain('참조인프라팀 ')
+ expect(bodyText(wrapper)).toContain('김아라님, 안녕하세요.')
expect(attachmentNames(wrapper)).toEqual(['체크리스트.xlsx', '견적.pdf'])
})
@@ -399,6 +460,240 @@ describe('ComposePage', () => {
expect(sendRequest(requests)).toBeUndefined()
})
+ describe('서식 본문 (계약 2절 "서식 본문")', () => {
+ const uploadApi: MockHandler = (request) => {
+ if (request.method === 'POST' && request.url === '/mail-attachments') {
+ const file = (request.data as FormData).get('file') as File
+ return ok({ id: `up-${file.name}`, name: file.name, type: file.type, size: file.size }, 201)
+ }
+ return replyApi(request)
+ }
+
+ async function addRecipient(wrapper: VueWrapper) {
+ const input = wrapper.get('#compose-to')
+ await input.setValue('doyun.lee@arasystem.it.kr')
+ await input.trigger('blur')
+ }
+
+ function uploadedNames(requests: MockRequest[]): string[] {
+ return requests
+ .filter((request) => request.method === 'POST' && request.url === '/mail-attachments')
+ .map((request) => ((request.data as FormData).get('file') as File).name)
+ }
+
+ it('쓴 서식은 서식 본문(bodyHtml)으로, 같은 내용은 텍스트(body)로 함께 보낸다', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
+ await addRecipient(wrapper)
+ editorOf(wrapper.element).commands.setContent('확인했습니다. DNS 낮추기 ')
+
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+
+ const payload = sendRequest(requests)?.data as Record | undefined
+ expect(payload?.body).toBe('확인했습니다.\n1. DNS 낮추기')
+ expect(payload?.bodyHtml).toBe(
+ `` +
+ ' 확인했습니다. ' +
+ ' DNS 낮추기
',
+ )
+ })
+
+ it('본문이 비었으면 서식 본문 없이 빈 텍스트로 보낸다 (텍스트 메일)', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
+ await addRecipient(wrapper)
+
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+
+ const payload = sendRequest(requests)?.data as Record | undefined
+ expect(payload?.body).toBe('')
+ expect(payload).not.toHaveProperty('bodyHtml')
+ })
+
+ it('본문에 붙여 넣은 그림은 "붙여넣은 이미지 1.png" 첨부로 올리고 알린다 — 본문에는 넣지 않는다', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi)
+
+ wrapper
+ .get('.ProseMirror')
+ .element.dispatchEvent(pasteEvent({ files: [new File(['png'], 'image.png', { type: 'image/png' })] }))
+ await flushPromises()
+
+ expect(uploadedNames(requests)).toEqual(['붙여넣은 이미지 1.png'])
+ expect(wrapper.findAll('.upload-item__name').map((item) => item.text())).toEqual(['붙여넣은 이미지 1.png'])
+ expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
+ '이미지는 본문 대신 첨부 파일로 올렸습니다.',
+ ])
+ expect(wrapper.find('#compose-body img').exists()).toBe(false)
+ })
+
+ it('파일을 끄는 동안 본문 위에 덮개를 보이고, 쓰기 화면 어디에 놓아도 첨부로 올린다 — 이미지가 아니면 알리지 않는다', async () => {
+ // 끌기는 화면(window) 전체에서 받는다 — 문서에 붙여야 window 까지 올라간다
+ const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
+ const report = new File(['pdf'], '보고서.pdf', { type: 'application/pdf' })
+
+ wrapper.get('#compose-subject').element.dispatchEvent(fileDragEvent('dragenter'))
+ await flushPromises()
+ expect(wrapper.get('.mail-editor__drop').text()).toBe('놓으면 첨부 파일로 올립니다')
+
+ // 첨부 영역에 놓는다 (본문 밖)
+ wrapper.get('.compose__attachments').element.dispatchEvent(fileDragEvent('drop', [report]))
+ await flushPromises()
+
+ expect(wrapper.find('.mail-editor__drop').exists()).toBe(false)
+ expect(uploadedNames(requests)).toEqual(['보고서.pdf'])
+ expect(useToastStore().toasts).toEqual([])
+
+ // 칸 사이를 지나도 깜빡이지 않는다 — 들어온 만큼 나가야(쓰기 화면 밖으로) 끈다
+ const subjectInput = wrapper.get('#compose-subject').element
+ const body = wrapper.get('.ProseMirror').element
+ subjectInput.dispatchEvent(fileDragEvent('dragenter'))
+ body.dispatchEvent(fileDragEvent('dragenter'))
+ subjectInput.dispatchEvent(fileDragEvent('dragleave'))
+ await flushPromises()
+ expect(wrapper.find('.mail-editor__drop').exists()).toBe(true)
+
+ body.dispatchEvent(fileDragEvent('dragleave', [], { relatedTarget: null }))
+ await flushPromises()
+ expect(wrapper.find('.mail-editor__drop').exists()).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('쓰기 칸(폼) 밖 빈 곳에 놓아도 첨부로 올리고 브라우저가 파일을 열지 않게 한다 — 화면을 떠나면 받지 않는다', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
+ const report = new File(['pdf'], '회의록.pdf', { type: 'application/pdf' })
+
+ const over = fileDragEvent('dragover')
+ document.body.dispatchEvent(over)
+ const drop = fileDragEvent('drop', [report])
+ document.body.dispatchEvent(drop)
+ await flushPromises()
+
+ expect(over.defaultPrevented).toBe(true)
+ expect(drop.defaultPrevented).toBe(true)
+ expect(uploadedNames(requests)).toEqual(['회의록.pdf'])
+
+ wrapper.unmount()
+ const later = fileDragEvent('drop', [report])
+ document.body.dispatchEvent(later)
+ await flushPromises()
+ expect(later.defaultPrevented).toBe(false)
+ expect(uploadedNames(requests)).toEqual(['회의록.pdf'])
+ })
+
+ it('붙여 넣은 그림이 사전 검사에 걸리면(허용 밖 형식) 올리지 않고 "첨부로 올렸습니다" 알림도 띄우지 않는다', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi)
+
+ wrapper
+ .get('.ProseMirror')
+ .element.dispatchEvent(pasteEvent({ files: [new File(['bmp'], 'image.bmp', { type: 'image/bmp' })] }))
+ await flushPromises()
+
+ expect(uploadedNames(requests)).toEqual([])
+ expect(wrapper.get('.upload-item--failed .upload-item__error').text()).toBe(ERROR_CODE_MESSAGES.FILE_001)
+ expect(useToastStore().toasts).toEqual([])
+ })
+
+ it('본문에 놓은 파일은 편집기가 넘긴 한 번만 올린다 (화면 전체의 놓기 처리는 건너뛴다)', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
+ const photo = new File(['png'], '현장.png', { type: 'image/png' })
+
+ wrapper.get('.ProseMirror').element.dispatchEvent(fileDragEvent('drop', [photo]))
+ await flushPromises()
+
+ expect(uploadedNames(requests)).toEqual(['현장.png'])
+ expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
+ '이미지는 본문 대신 첨부 파일로 올렸습니다.',
+ ])
+ wrapper.unmount()
+ })
+
+ it('입력칸마다 같은 포커스 표시 — 받는 사람 · 참조 · 숨은참조 · 제목 행과 본문 편집 영역에 compose-field', async () => {
+ const { wrapper } = await mountCompose('/mail/compose', replyApi)
+
+ expect(wrapper.findAll('.compose-field')).toHaveLength(5)
+ expect(wrapper.get('#compose-subject').element.closest('.compose-field')).not.toBeNull()
+ expect(wrapper.get('#compose-body').element.closest('.compose-field')).not.toBeNull()
+ // 툴바(링크 팝오버의 입력칸)는 본문 선 밖이다 — 테두리 있는 칸은 고리를 쓴다
+ expect(wrapper.get('[role="toolbar"]').element.closest('.compose-field')).toBeNull()
+ })
+ })
+
+ describe('보내기 직전의 길이 — 제목 255 · 본문 텍스트 100,000 · 서식 500,000 (계약 2절)', () => {
+ async function addRecipient(wrapper: VueWrapper) {
+ const input = wrapper.get('#compose-to')
+ await input.setValue('doyun.lee@arasystem.it.kr')
+ await input.trigger('blur')
+ }
+
+ it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(VAL_001) — 본문으로 초점, 고치면 내린다', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
+ await addRecipient(wrapper)
+ const editor = editorOf(wrapper.element)
+ editor.commands.setContent(`${'가'.repeat(MAIL_BODY_MAX_LENGTH + 1)} `)
+
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+
+ expect(sendRequest(requests)).toBeUndefined()
+ expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
+ expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
+ expect(document.activeElement?.id).toBe('compose-body')
+ expect(useToastStore().toasts).toEqual([])
+
+ editor.commands.insertContent('나')
+ await flushPromises()
+ expect(wrapper.find('#compose-body-error').exists()).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('답장 원문이 길어도 맞춘 초안에 내 글을 쓰면 보낸다 — 남겨 둔 자리보다 길면 인용을 더 줄여(생략 표시) 보낸다', async () => {
+ const longOriginal = { ...ORIGINAL, textBody: `${'긴 원문 줄입니다. '.repeat(10)}\n`.repeat(1_000) }
+ const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', (request) =>
+ request.method === 'GET' && request.url === '/messages/m1' ? ok(longOriginal) : replyApi(request),
+ )
+ const mine = '내가 쓴 긴 답장입니다. '.repeat(600)
+ editorOf(wrapper.element).commands.insertContentAt(1, mine)
+
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+
+ const payload = sendRequest(requests)?.data as { body: string; bodyHtml: string } | undefined
+ expect(payload?.body.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
+ expect(payload?.bodyHtml.length).toBeLessThanOrEqual(MAIL_BODY_HTML_MAX_LENGTH)
+ expect(payload?.body.startsWith(mine.trimEnd())).toBe(true)
+ expect(payload?.body.endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
+ })
+
+ it('제목이 255자를 넘으면(붙여 넣기 등) 보내지 않고 제목 아래에 사전 문구 — 제목으로 초점', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
+ await addRecipient(wrapper)
+ await wrapper.get('#compose-subject').setValue('제'.repeat(MAIL_SUBJECT_MAX_LENGTH + 1))
+
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+
+ expect(sendRequest(requests)).toBeUndefined()
+ expect(wrapper.get('#compose-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
+ expect(wrapper.get('#compose-subject').attributes('aria-invalid')).toBe('true')
+ expect(document.activeElement?.id).toBe('compose-subject')
+ wrapper.unmount()
+ })
+
+ it('제목에서 Enter 를 누르면 본문의 커서 자리 그대로 본문으로 간다', async () => {
+ const { wrapper } = await mountCompose('/mail/compose', replyApi, true)
+ const editor = editorOf(wrapper.element)
+ editor.commands.setContent('첫 줄 둘째 줄 ')
+ editor.commands.setTextSelection(8)
+ ;(wrapper.get('#compose-subject').element as HTMLInputElement).focus()
+
+ await wrapper.get('#compose-subject').trigger('keydown', { key: 'Enter' })
+
+ expect(document.activeElement?.id).toBe('compose-body')
+ expect(editor.state.selection.from).toBe(8)
+ wrapper.unmount()
+ })
+ })
+
describe('중복 발송 방지 키 (Idempotency-Key, 계약 2절)', () => {
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
diff --git a/frontend/src/pages/mail/ComposePage.vue b/frontend/src/pages/mail/ComposePage.vue
index 9955f7d..2255e0d 100644
--- a/frontend/src/pages/mail/ComposePage.vue
+++ b/frontend/src/pages/mail/ComposePage.vue
@@ -1,22 +1,33 @@
',
+ 'vbscript:msgbox(1)',
+ 'file:///etc/passwd',
+ 'ftp://example.kr',
+ '//example.kr',
+ '/mail/inbox',
+ 'https://',
+ 'https://exa mple.kr',
+ 'https://example.kr\u0000',
+ ' https://example.kr',
+ ])('받지 않는다: %s', (href) => {
+ expect(isAllowedLinkHref(href)).toBe(false)
+ })
+})
+
+describe('normalizeLinkHref — 링크 칸에 쓴 주소', () => {
+ it('프로토콜이 없으면 붙인다 — 메일 주소는 mailto:, 그 밖은 https://', () => {
+ expect(normalizeLinkHref('wiki.arasystem.it.kr/a')).toBe('https://wiki.arasystem.it.kr/a')
+ expect(normalizeLinkHref(' ara.kim@arasystem.it.kr ')).toBe('mailto:ara.kim@arasystem.it.kr')
+ expect(normalizeLinkHref('https://example.kr')).toBe('https://example.kr')
+ })
+
+ it('쓸 수 없는 주소는 null — javascript: 를 https:// 로 감싸 통과시키지 않는다', () => {
+ expect(normalizeLinkHref('javascript:alert(1)')).toBeNull()
+ expect(normalizeLinkHref('')).toBeNull()
+ expect(normalizeLinkHref(' ')).toBeNull()
+ expect(normalizeLinkHref('exa mple')).toBeNull()
+ })
+})
+
+describe('shouldAutoLinkText — 쓰는 중에 저절로 링크로 바꿀 글', () => {
+ it.each(['www.naver.com', 'naver.com', 'ara.kim@arasystem.it.kr', 'https://wiki.arasystem.it.kr/a', 'mailto:ara@x.kr'])(
+ '바꾼다(프로토콜이 없어도): %s',
+ (text) => {
+ expect(shouldAutoLinkText(text)).toBe(true)
+ },
+ )
+
+ it.each(['1.2.3.4', '10.0.0.1/admin', 'javascript:alert(1)', 'ftp://example.kr', 'exa mple.kr', ''])(
+ '바꾸지 않는다 — 프로토콜 없는 IP(버전 번호 등) · 허용 밖 프로토콜: %s',
+ (text) => {
+ expect(shouldAutoLinkText(text)).toBe(false)
+ },
+ )
+})
diff --git a/frontend/src/shared/utils/link-href.ts b/frontend/src/shared/utils/link-href.ts
new file mode 100644
index 0000000..21e3fc9
--- /dev/null
+++ b/frontend/src/shared/utils/link-href.ts
@@ -0,0 +1,51 @@
+// 본문 링크 주소 검사 — 순수 함수 (계약 2절 "서식 본문": 링크는 http · https · mailto 만)
+// 서버 정화(sanitize-outgoing-html.ts)도 같은 규칙으로 그 밖의 링크를 글자만 남기지만,
+// 편집기에서부터 막아 javascript: 같은 주소가 본문에 들어가지 않게 한다.
+
+/** 허용하는 주소의 시작 — 서버의 SAFE_LINK 와 같다 */
+const SAFE_LINK = /^(?:https?:\/\/|mailto:)/i
+const ALLOWED_PROTOCOLS: readonly string[] = ['http:', 'https:', 'mailto:']
+/** 공백·제어 문자 — 주소 안에 있으면 받지 않는다 (숨은 문자로 검사를 비껴가지 못하게) */
+const UNSAFE_CHARACTERS = /[\s\p{Cc}]/u
+/** 프로토콜이 붙어 있는가 (예: https:, mailto:, javascript:) */
+const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i
+/** 메일 주소처럼 보이는가 — 그러면 mailto: 를 붙인다 */
+const EMAIL_LIKE = /^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/
+
+/** 링크로 둘 수 있는 주소인가 */
+export function isAllowedLinkHref(href: string): boolean {
+ if (!SAFE_LINK.test(href) || UNSAFE_CHARACTERS.test(href)) return false
+ try {
+ const url = new URL(href)
+ if (!ALLOWED_PROTOCOLS.includes(url.protocol)) return false
+ return url.protocol === 'mailto:' || url.hostname !== ''
+ } catch {
+ return false
+ }
+}
+
+/**
+ * 사용자가 링크 칸에 쓴 주소를 링크 주소로 바꾼다. 쓸 수 없으면 null.
+ * 프로토콜이 없으면 붙여 본다 — 메일 주소면 mailto:, 그 밖은 https://
+ */
+export function normalizeLinkHref(input: string): string | null {
+ const value = input.trim()
+ if (value === '') return null
+ let candidate = value
+ if (!HAS_SCHEME.test(value)) {
+ candidate = EMAIL_LIKE.test(value) ? `mailto:${value}` : `https://${value}`
+ }
+ return isAllowedLinkHref(candidate) ? candidate : null
+}
+
+/** 프로토콜 없이 쓴 IP 주소 — 버전 번호(1.2.3.4) 같은 글이라 저절로 링크로 바꾸지 않는다 (TipTap 기본 규칙과 같다) */
+const BARE_IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}(?:[/:?#]|$)/
+
+/**
+ * 쓰는 중에 저절로 링크로 바꿀 글인가 — 링크 칸과 같은 규칙으로 프로토콜을 붙여 본다.
+ * 그래서 www.naver.com · naver.com · 메일 주소도 링크가 된다 (실제 주소는 편집기가 https:// · mailto: 를 붙인다).
+ */
+export function shouldAutoLinkText(text: string): boolean {
+ if (!HAS_SCHEME.test(text) && BARE_IPV4.test(text)) return false
+ return normalizeLinkHref(text) !== null
+}
diff --git a/frontend/src/shared/utils/mail-draft.spec.ts b/frontend/src/shared/utils/mail-draft.spec.ts
index ab57982..6bcda95 100644
--- a/frontend/src/shared/utils/mail-draft.spec.ts
+++ b/frontend/src/shared/utils/mail-draft.spec.ts
@@ -1,11 +1,7 @@
import { describe, expect, it } from 'vitest'
-import {
- MAIL_BODY_MAX_LENGTH,
- MAIL_QUOTE_OMITTED_MARK,
- MAIL_SUBJECT_MAX_LENGTH,
-} from '@/shared/constants/mail'
-import { fitDraftBody, fitDraftSubject, truncateText } from './mail-draft'
+import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
+import { fitDraftSubject, truncateText } from './mail-draft'
describe('truncateText', () => {
it('한도 안이면 그대로, 넘으면 앞에서부터 한도까지 남긴다', () => {
@@ -32,38 +28,3 @@ describe('fitDraftSubject — 제목 255자 (계약 2절)', () => {
expect(fitDraftSubject(over).startsWith('Re: ')).toBe(true)
})
})
-
-describe('fitDraftBody — 본문 100,000자 (계약 2절)', () => {
- const head = '\n\n──────────\n2026. 10. 26. (월) 오전 10:42, 이도윤 작성:\n'
-
- it('100,000자면 그대로 둔다', () => {
- const exact = head + 'x'.repeat(MAIL_BODY_MAX_LENGTH - head.length)
-
- expect(fitDraftBody(exact)).toBe(exact)
- })
-
- it('100,001자면 뒤쪽 원문을 줄이고 끝에 생략 표시를 둔다 — 앞쪽(쓸 자리·구분선·원문 머리)은 그대로', () => {
- const over = head + 'x'.repeat(MAIL_BODY_MAX_LENGTH - head.length + 1)
-
- const fitted = fitDraftBody(over)
-
- expect(fitted.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
- expect(fitted.startsWith(head)).toBe(true)
- expect(fitted.endsWith(`\n${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
- })
-
- it('답장은 생략 표시도 인용 줄 머리("> ")를 붙여 인용 안에 둔다', () => {
- const over = head + '> 원문 줄\n'.repeat(20_000)
-
- const fitted = fitDraftBody(over, '> ')
-
- expect(fitted.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
- expect(fitted.endsWith(`\n> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
- })
-
- it('아주 긴 원문(1MB 넘게)도 한도 안으로 줄인다 — 앞단 프록시의 413 으로 가지 않게', () => {
- const huge = head + '가'.repeat(1_100_000)
-
- expect(fitDraftBody(huge).length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
- })
-})
diff --git a/frontend/src/shared/utils/mail-draft.ts b/frontend/src/shared/utils/mail-draft.ts
index 423a0f3..88afcfa 100644
--- a/frontend/src/shared/utils/mail-draft.ts
+++ b/frontend/src/shared/utils/mail-draft.ts
@@ -1,13 +1,9 @@
-// 답장·전달 초안을 계약 한도에 맞춘다 — 순수 함수만 둔다 (계약 2절 "답장·전달 초안의 길이")
-// 서버는 제목 255자 · 본문 100,000자를 넘으면 VAL_001 로 거절한다. 본문이 1MB 를 넘으면 앞단 프록시가
-// 413(FILE_002)으로 끊어 첨부 문제처럼 보이므로, 초안을 만들 때 미리 맞춘다.
+// 답장·전달 초안의 제목을 계약 한도에 맞춘다 — 순수 함수만 둔다 (계약 2절 "답장·전달 초안의 길이")
+// 서버는 제목 255자를 넘으면 VAL_001 로 거절한다. 본문(텍스트 100,000 · 서식 500,000)은 서식 편집기가 맞춘다
+// (components/mail/editor/fit-draft.ts).
// 길이는 JS 문자열 길이(UTF-16 단위)로 센다 — 서버(글자 수)보다 같거나 길게 세므로 이 한도 안이면 서버도 통과한다.
-import {
- MAIL_BODY_MAX_LENGTH,
- MAIL_QUOTE_OMITTED_MARK,
- MAIL_SUBJECT_MAX_LENGTH,
-} from '@/shared/constants/mail'
+import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
/** UTF-16 서로게이트 쌍의 앞쪽 범위 — 여기서 자르면 이모지 등이 깨진다 */
const HIGH_SURROGATE_START = 0xd800
@@ -26,13 +22,3 @@ export function truncateText(text: string, maxLength: number): string {
export function fitDraftSubject(subject: string): string {
return truncateText(subject, MAIL_SUBJECT_MAX_LENGTH)
}
-
-/**
- * 본문 — 100,000자를 넘으면 뒤쪽(인용한 원문)을 줄이고 끝에 생략 표시를 둔다. 앞쪽(내가 쓸 자리 · 구분선 · 원문 머리)은 남는다.
- * linePrefix: 답장은 인용 줄 머리("> ")를 붙여 생략 표시도 인용 안에 둔다.
- */
-export function fitDraftBody(body: string, linePrefix = ''): string {
- if (body.length <= MAIL_BODY_MAX_LENGTH) return body
- const mark = `\n${linePrefix}${MAIL_QUOTE_OMITTED_MARK}`
- return `${truncateText(body, MAIL_BODY_MAX_LENGTH - mark.length).trimEnd()}${mark}`
-}
diff --git a/frontend/src/shared/utils/mail-forward.spec.ts b/frontend/src/shared/utils/mail-forward.spec.ts
index 5c6bdda..d2d01c3 100644
--- a/frontend/src/shared/utils/mail-forward.spec.ts
+++ b/frontend/src/shared/utils/mail-forward.spec.ts
@@ -1,12 +1,8 @@
import { describe, expect, it } from 'vitest'
-import {
- MAIL_BODY_MAX_LENGTH,
- MAIL_QUOTE_OMITTED_MARK,
- MAIL_SUBJECT_MAX_LENGTH,
-} from '@/shared/constants/mail'
+import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
-import { buildForwardBody, buildForwardDraft, buildForwardSubject } from './mail-forward'
+import { buildForwardDraft, buildForwardSubject } from './mail-forward'
const ATTACHMENT = { id: 'att1', name: '메일서버_전환_체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
@@ -52,42 +48,37 @@ describe('buildForwardSubject', () => {
})
})
-describe('buildForwardBody', () => {
- it('구분선 + 원문 머리(보낸 사람·날짜·제목·받는 사람·참조) + 원문 그대로', () => {
- expect(buildForwardBody(message(), 'Asia/Seoul')).toBe(
- [
- '',
- '',
- '──────────',
- '전달된 메일',
- '보낸 사람: 이도윤 ',
- '날짜: 2026. 10. 26. (월) 오전 10:41',
- '제목: 메일 서버 이전 일정 공유',
- '받는 사람: 김아라 ',
- '참조: 인프라팀 ',
- '',
- '김아라님, 안녕하세요.',
- '',
- '금요일 저녁에 전환하겠습니다.',
- ].join('\n'),
- )
- })
-
- it('참조가 없으면 참조 줄을 빼고, 텍스트 본문이 없으면 HTML 을 텍스트로 바꾼다', () => {
- const body = buildForwardBody(message({ cc: [], textBody: null, htmlBody: '첫 줄 둘째 줄 ' }), 'Asia/Seoul')
-
- expect(body).not.toContain('참조:')
- expect(body.endsWith('\n\n첫 줄\n둘째 줄')).toBe(true)
- })
-})
-
describe('buildForwardDraft', () => {
it('원본 첨부를 그대로 넘긴다 (발송 때 id 를 다시 보낸다)', () => {
const draft = buildForwardDraft(message(), 'Asia/Seoul')
expect(draft.subject).toBe('Fwd: 메일 서버 이전 일정 공유')
expect(draft.attachments).toEqual([ATTACHMENT])
- expect(draft.body).toContain('전달된 메일')
+ })
+
+ it('본문 — 답장과 같은 "원본 메일" 머리에 받는 사람 · 참조까지 (보낸 사람 · 날짜 · 받는 사람 · 참조 · 제목)', () => {
+ const { html } = buildForwardDraft(message(), 'Asia/Seoul')
+
+ expect(html).toBe(
+ '' +
+ '원본 메일 ' +
+ '' +
+ '| 보낸 사람 | 이도윤 <doyun.lee@arasystem.it.kr> | ' +
+ '| 날짜 | 2026. 10. 26. (월) 오전 10:41 | ' +
+ '| 받는 사람 | 김아라 <ara.kim@arasystem.it.kr> | ' +
+ '| 참조 | 인프라팀 <infra@arasystem.it.kr> | ' +
+ '| 제목 | 메일 서버 이전 일정 공유 | ' +
+ ' ' +
+ '김아라님, 안녕하세요. 금요일 저녁에 전환하겠습니다. ' +
+ ' ',
+ )
+ })
+
+ it('참조가 없으면 참조 줄을 뺀다', () => {
+ const { html } = buildForwardDraft(message({ cc: [] }), 'Asia/Seoul')
+
+ expect(html).toContain('받는 사람 | ')
+ expect(html).not.toContain('참조 | ')
})
it('계약 한도에 맞춘다 — 원래 제목 250자면 "Fwd: " 를 붙여 255자, 251자면 255자에서 자른다', () => {
@@ -97,12 +88,4 @@ describe('buildForwardDraft', () => {
expect(fits.subject).toBe(`Fwd: ${'가'.repeat(250)}`)
expect(over.subject).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
})
-
- it('원문이 본문 한도(100,000자)를 넘으면 원문을 줄이고 끝에 생략 표시를 둔다 — 원문 머리는 남는다', () => {
- const draft = buildForwardDraft(message({ textBody: '가'.repeat(150_000) }))
-
- expect(draft.body.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
- expect(draft.body).toContain('보낸 사람: 이도윤 ')
- expect(draft.body.endsWith(`\n${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
- })
})
diff --git a/frontend/src/shared/utils/mail-forward.ts b/frontend/src/shared/utils/mail-forward.ts
index 42de438..74f3fa9 100644
--- a/frontend/src/shared/utils/mail-forward.ts
+++ b/frontend/src/shared/utils/mail-forward.ts
@@ -1,16 +1,16 @@
// 전달 초안 만들기 — 순수 함수만 둔다 (계약 2절 "전달")
-// 제목 "Fwd:" 와 원문 본문은 클라이언트가 만들고, 원본 첨부는 id 를 그대로 다시 보낸다. inReplyTo 는 보내지 않는다.
+// 제목 "Fwd:" 와 원문 인용은 클라이언트가 만들고, 원본 첨부는 id 를 그대로 다시 보낸다. inReplyTo 는 보내지 않는다.
+// 본문은 서식 편집기에 넣을 HTML 이다 — "원본 메일" 머리(보낸 사람·날짜·받는 사람·참조·제목)와 회색 인용 막대 (mail-quote.ts).
-import { MAIL_FORWARD_HEADING, MAIL_FORWARD_PREFIX, MAIL_QUOTE_SEPARATOR } from '@/shared/constants/mail'
+import { MAIL_FORWARD_PREFIX } from '@/shared/constants/mail'
import type { Attachment, MessageDetail } from '@/shared/types/mail'
-import { formatMailDateTime } from '@/shared/utils/format'
-import { formatAddress, formatAddressList } from '@/shared/utils/mail-address'
-import { fitDraftBody, fitDraftSubject } from '@/shared/utils/mail-draft'
-import { messageBodyText } from '@/shared/utils/mail-html'
+import { fitDraftSubject } from '@/shared/utils/mail-draft'
+import { buildQuoteHtml, forwardQuoteRows, originalBodyHtml } from '@/shared/utils/mail-quote'
export interface ForwardDraft {
subject: string
- body: string
+ /** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 전달하는 말을 쓴다) + 원문 인용 */
+ html: string
/** 원본 메일의 첨부 — 쓰기 화면의 첨부 목록에 미리 올려 두고 하나씩 뺄 수 있다 */
attachments: Attachment[]
}
@@ -24,43 +24,11 @@ export function buildForwardSubject(subject: string): string {
return `${MAIL_FORWARD_PREFIX}${trimmed}`.trimEnd()
}
-type ForwardSource = Pick<
- MessageDetail,
- 'from' | 'to' | 'cc' | 'subject' | 'receivedAt' | 'sentAt' | 'textBody' | 'htmlBody'
->
-
-/**
- * 전달 본문 — 원문 머리(보낸 사람·날짜·제목·받는 사람·참조) 뒤에 원문을 그대로 붙인다.
- *
- * (빈 줄 — 여기에 전달하는 말을 쓴다)
- *
- * ──────────
- * 전달된 메일
- * 보낸 사람: 이도윤
- * 날짜: 2026. 10. 26. (월) 오전 10:41
- * 제목: 메일 서버 이전 일정 공유
- * 받는 사람: 김아라
- *
- * 원문 …
- */
-export function buildForwardBody(message: ForwardSource, timeZone?: string): string {
- const header = [
- MAIL_FORWARD_HEADING,
- message.from ? `보낸 사람: ${formatAddress(message.from)}` : null,
- `날짜: ${formatMailDateTime(message.sentAt ?? message.receivedAt, timeZone)}`,
- `제목: ${message.subject || '(제목 없음)'}`,
- message.to.length > 0 ? `받는 사람: ${formatAddressList(message.to)}` : null,
- message.cc.length > 0 ? `참조: ${formatAddressList(message.cc)}` : null,
- ].filter((line): line is string => line !== null)
-
- return `\n\n${MAIL_QUOTE_SEPARATOR}\n${header.join('\n')}\n\n${messageBodyText(message)}`
-}
-
-/** 전달 초안 — 제목·본문은 계약 한도(255자·100,000자)에 맞춘다. 넘치면 원문을 줄이고 끝에 생략 표시를 둔다 */
+/** 전달 초안 — 제목은 255자에 맞춘다 */
export function buildForwardDraft(message: MessageDetail, timeZone?: string): ForwardDraft {
return {
subject: fitDraftSubject(buildForwardSubject(message.subject)),
- body: fitDraftBody(buildForwardBody(message, timeZone)),
+ html: buildQuoteHtml(forwardQuoteRows(message, timeZone), originalBodyHtml(message)),
attachments: [...message.attachments],
}
}
diff --git a/frontend/src/shared/utils/mail-html.spec.ts b/frontend/src/shared/utils/mail-html.spec.ts
index af497fd..f7cd981 100644
--- a/frontend/src/shared/utils/mail-html.spec.ts
+++ b/frontend/src/shared/utils/mail-html.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { MAIL_FRAME_CSP, buildMailDocument, escapeHtml, htmlToText, sanitizeMailHtml } from './mail-html'
+import { MAIL_FRAME_CSP, buildMailDocument, escapeHtml, sanitizeMailHtml } from './mail-html'
// 보안 핵심 — 서버 정화에 빈틈이 있어도 클라이언트가 한 번 더 걸러 내는지 확인한다.
describe('sanitizeMailHtml', () => {
@@ -131,15 +131,3 @@ describe('escapeHtml', () => {
expect(escapeHtml(`'&'`)).toBe('<a href="x">'&'</a>')
})
})
-
-describe('htmlToText (답장 인용용)', () => {
- it('문단·줄바꿈을 살려 텍스트로 바꾼다', () => {
- expect(htmlToText('김아라님, \n 안녕하세요. 확인 부탁드립니다. ')).toBe(
- '김아라님,\n\n안녕하세요.\n확인 부탁드립니다.',
- )
- })
-
- it('스크립트 내용은 텍스트로 새지 않는다', () => {
- expect(htmlToText('본문 ')).toBe('본문')
- })
-})
diff --git a/frontend/src/shared/utils/mail-html.ts b/frontend/src/shared/utils/mail-html.ts
index ce19b0e..01a8dc8 100644
--- a/frontend/src/shared/utils/mail-html.ts
+++ b/frontend/src/shared/utils/mail-html.ts
@@ -154,68 +154,3 @@ export function buildMailDocument(source: MailBodySource): string {
' |