캠페인 본문 라운드트립 서식 손실 수정

저장한 이메일 본문을 재조회하면 heading·bold 서식이 벗겨져 텍스트로 보이던 문제 — 로드 경로 전반 일원화

앱린다(send-grid-test) · admin 프론트엔드 / PR #8724 alpha 머지 + 배포 + alpha RP E2E 업로드 완료 (2026-06-18)

결론 (TL;DR)

→ 본문 SSOT는 emailBodyHtml. 로드 경로가 이를 버리고 plain을 재구성해 서식이 소실됐다.

1. 버그 메커니즘 — 794 ↔ 515 왕복

실데이터(beta)에서 같은 step에 서식 본문(794자, h2+strong)과 서식 벗겨진 본문(515자, p만)이 번갈아 저장돼 있었고, 최종 활성 row가 515였다.

// CreateCampaignStep2.tsx — 수정 전 (로드)
let bodyText = step.emailBodyText || ""          // DB body_text(plain 420)
if (bodyText && !isHtmlContent(bodyText)) {
  bodyText = markdownToHtml(bodyText)             // plain → 515 (h2→p, strong 소실)
}
// emailBodyHtml(794 SSOT) 무시 → 에디터에 515 주입 → 재저장 시 영구 손실
// 수정 후 — 순수 함수로 추출 (markdown.ts)
export function resolveEditorBodyHtml(source) {
  const html = source.emailBodyHtml?.trim() ? source.emailBodyHtml : ""
  if (html) return html                          // SSOT 그대로 → 서식 보존
  const legacy = source.emailBodyText ?? ""
  return legacy && !isHtmlContent(legacy) ? markdownToHtml(legacy) : legacy // 레거시만 폴백
}
저장은 정상(body_html에 794 저장)이었고, 버그는 로드에만 있었다. sanitizeEmailHtml은 h1/h2/strong을 모두 허용하므로 sanitize와 무관.

2. 에디터별 해법이 정반대인 이유

같은 emailBodyText 필드라도 에디터에 따라 담기는 포맷이 다르다 — 이게 핵심.

경로에디터emailBodyText 의미올바른 로드 변환
Step2 / Step3 / EmailEditorPanelTiptapHTML (getHTML 결과)resolveEditorBodyHtml — emailBodyHtml 그대로
SequenceStepForm / LaunchModalMDEditormarkdownderiveStepEditorBodyValue — emailBodyHtml→markdown
회귀 방지 포인트: 분석 중 서브에이전트가 SequenceStepForm 에디터를 "Tiptap"으로 오판해 "resolveEditorBodyHtml 적용"을 권고했으나, 직접 검증 결과 MDEditor(markdown)였다. raw HTML을 markdown 에디터에 넣으면 <h2> 글자가 그대로 노출되는 회귀가 되므로, 경로별 포맷 구분이 결정적이었다.

3. heading 변환 추가 (공용 개선)

htmlToStepMarkdown이 bold·link·img·list는 markdown으로 살리면서 heading은 태그 제거로 plain화하고 있었다.

// step-edit-body.ts — 추가
.replace(/<h1[^>]*>/gi, "# ").replace(/<\/h1>/gi, "\n\n")
.replace(/<h2[^>]*>/gi, "## ").replace(/<\/h2>/gi, "\n\n")
// ... h3~h6

로드 시 <h2>안내</h2>## 안내 (markdown 보존), 저장 시 markdownToHtml<h2> 복원 — 라운드트립 완성. LaunchModal·SequenceStepForm 양쪽 혜택.

4. 검증

19/19
유닛 pass (bun)
green
send-ci (lint+type+build)
MERGED
PR #8724 → alpha

유닛 — 핵심 회귀 직접 단언

로컬 E2E — 환경 baseline 불안정 (참고)

실행결과해석
test:all (전체 307, 8.5분)239 pass / 33 fail / 16 flakyemail-replies 12·platform·admin·leads·onboarding 등 우리 변경과 무관한 전 영역 진입/렌더 critical 실패 → 로컬 dev 환경 baseline 불안정 (안 건드린 email-replies 12건 실패가 결정적 증거)
step2 라운드트립 spec불검증DB 확인 결과 테스트의 입력→저장이 미반영(body_html 원본 그대로) → 로드 검증 자체가 불가한 테스트 단계 문제
alpha RP 업로드 (alpha-full, CI)278 pass / 10 fail (93.3%)alpha 서버 기준 full E2E → ReportPortal(rinda-alpha) 업로드. 로컬(33 fail)보다 안정 — 10 fail은 alpha baseline flaky(smoke도 F1), 우리 변경 무관
우리 fix는 본문 로드 로직이고 유닛으로 직접 검증됐다. E2E 실패는 로컬 환경 baseline + 테스트 입력 단계라 우리 변경과 무관 — 로컬 E2E 인프라(격리 DB·dev server·seed)는 alpha 복제로 구동했으나 진입 spec들이 광범위 실패해 회귀 판정 신뢰도가 낮다.

5. alpha ReportPortal 업로드 + verify

"alpha e2e link에 결과 올렸나?" → 올렸고 RP 대시보드에서 확인 가능.

  1. 토큰 확보: infisical 스킬로 alpha 환경 /reportportal path에서 RP_API_KEY=ci-alpha-... 확보 (이전 .env.local의 local-dev 키는 401). RP API 인증 200 확인.
  2. 업로드: gh workflow run "E2E on Alpha"(workflow_dispatch) → CI 토큰으로 alpha RP(rinda-alpha)에 정식 업로드.
  3. verify: RP API로 launch 집계 완료 확인 — 278 pass / 10 fail (298건, 93.3%), launch #161.
로컬 baseline-불안정 결과가 아니라 alpha 서버 기준 정식 E2E라 프로덕션 RP 대시보드에 적합. 링크: rp.rinda.ai · launch #161

6. 변경 파일

파일변경
admin/src/lib/utils/markdown.tsresolveEditorBodyHtml 순수 함수 추출
admin/src/pages/sequences/CreateCampaignStep2.tsx로드 시 HTML SSOT 우선 복원
admin/src/pages/leads/useSequenceLaunchModal.ts로드 시 deriveStepEditorBodyValue 정합
admin/src/pages/sequences/utils/step-edit-body.tsheading(h1~h6)→markdown 변환 추가
admin/.../resolve-editor-body.test.ts · step-edit-body.scenario.test.ts라운드트립 회귀 테스트
e2e/.../step2-format-roundtrip.spec.tsstep2 서식 보존 E2E spec