← 목록
기타 2026-07-26 10KB 읽기 10분

크라우니메일 v3 — 실사용 버그 2건 수리 (MIME 본문 파싱 / JSON 이스케이프 / 답장 주소 손상)

개요

사장님 실사용에서 발견된 크라우니메일(mail.crowny.org:9610) 버그 2건을 원인 규명 → 수리 → 실측 재현/검증까지 완료. 라이브 서비스(:9610, 실데이터)는 무접촉 — .한선 소스만 수정하고 /tmp/메일v3.toau, /tmp/smtp수신.toau로 컴파일 산출, 별도 포트의 스크래치 인스턴스로만 검증. 라이브 배포(재컴파일+재기동)는 별도 세션/사장님 확인 필요.

버그①: 수신 메일 본문이 안 보인다 (MIME 미파싱 + JSON 미이스케이프)

원인

  1. /Users/ef/crowny-mail/크라우니메일-SMTP수신.한선 _DATA완료처리()(구 134행대)가
헤더/본문을 1회만 분리하고, gmail 등이 보내는 multipart/alternative(boundary + text/plain·text/html 파트, 종종 base64/quoted-printable) 본문을 전혀 파싱하지 않고 그대로 ingest content로 전달 — MIME 원본(--boundary\nContent-Type: ...)이 그대로 저장됐다.
  1. /Users/ef/crowny-mail/크라우니메일서버.한선_JSON필드()(구 279행대)가 여는
따옴표 뒤 " 를 무조건 종결자로 취급(백슬래시 이스케이프 미고려) — 값 안에 이스케이프된 따옴표(\")가 있으면(예: 위 MIME 원본에 섞여 들어온 charset=" 헤더 줄) 그 지점에서 값이 잘렸다. 이게 실측 증상 "...charset=\" 에서 절단"의 정확한 원인.
  1. 메일상세v3()/메일목록v3()(및 레거시 v2 메일목록/메일읽기/메일검색)가
content/subject/body/preview/from/fromName을 이스케이프 없이 그대로 JSON에 꽂아 넣어, 저장된 문자열에 따옴표·개행·백슬래시가 있으면 응답 JSON 자체가 깨졌다.

수리

  • 크라우니메일-SMTP수신.한선: _MIME최상위타입/_경계추출/_QP디코드/
_MIME파트분할/_MIME파트본문/_HTML평문화/_MIME평문추출 신규 함수 추가. boundary로 분할해 text/plain 파트 우선(없으면 text/html 태그제거 평문 폴백), CTE가 base64/quoted-printable이면 디코드. 1단계 중첩 multipart(mixed 안의 alternative) 재귀 대응. QP 디코드는 바이트 배열로 모아 Base64.한선_UTF8문자열로 재조립 (글자변환()을 바이트별로 개별 호출하면 codepoint→UTF-8 인코딩 함정에 걸려 다중바이트 UTF-8이 깨짐 — 가드레일 계열 함정 회피). _DATA완료처리()에서 헤더 분리 후 _MIME평문추출() 호출로 교체. 실측 버그: 첫 구현에서 시작하는가(...) == 0으로 단일파트 분기를 짰다가 자체 테스트로 즉시 적발 — 시작하는가/끝나는가는 3진 참(1)/거짓(-1) 반환이라 0은 절대 안 나옴(가드레일 3진 논리) → != 1로 수정.
  • 크라우니메일서버.한선: _JSON문자열끝()(백슬래시 이스케이프 skip하며 진짜 종결
따옴표 탐색) + _JSON문자열언이스케이프()(\" \\ \n \t \r \/ \uXXXX 복원)를 _JSON필드()에 적용(입력 파싱 수리). 출력 측 _JSON문자열이스케이프()(공용 헬퍼)를 메일상세v3/메일목록v3/메일목록/메일읽기/메일검색의 from/fromName/fromId/ to/subject/content/body/preview/canvas 필드에 적용.

버그②: UI 직접 답장 시 도착 안 함 (수신자 주소 변질)

원인

  • 크라우니메일서버.한선 메일목록v3()fromId
"@" + _아이디에서로컬(보낸이값)로 조립 — 도메인을 버리고 로컬파트만 남김 (kim.president.sk@gmail.com@kim.president.sk).
  • pages/앱v3.html "답장" 버튼이 c.name+' '+(c.id||c.em)로 수신자 chip을 만들고,
c.id(=위의 손상 fromId, truthy라 c.em보다 항상 우선)를 그대로 사용 → 표시텍스트와 발송값이 뒤섞인 채 doSend()r.txt를 그대로 payload.to로 전송 → "kim.president.sk @kim.president.sk"(공백 혼입 + 도메인 유실)가 되어 MX 조회가 존재하지 않는 도메인으로 나가 큐러너가 반복 재시도 후 bounced.
  • 부가로 발견: 컴포즈 화면 "빠른 추가 · 미연결 @yjin" 버튼도 동일 계열 버그(도메인
없는 @yjin 그대로 발송값이 됨) — 같이 수리.

수리

  1. 크라우니메일서버.한선: fromId를 완전 이메일 주소(from과 동일, 이스케이프 적용)로
변경 — 데이터 계층에서 도메인 유실 근본 차단. (메일상세v3에도 fromId 필드 추가)
  1. pages/앱v3.html: classifyRecipient()/답장 프리필이 txt(표시용)와
em(실제 발송 주소, 완전 이메일)을 분리 반환하도록 수정. doSend()r.em || r.txt를 payload.to로 사용. "@yjin" 같은 handle-only 입력도 em = 로컬+"@crowny.org"로 정규화.
  1. 방어(클라+서버 이중 게이트): 앱v3.html_이메일형식유효앱()(로컬파트@도메인,
공백 없음, @ 1개)로 발송 전 조기 차단 + 토스트. 서버 크라우니메일서버.한선 /api/mail/send에 동일 규칙의 _이메일형식유효()로 400 명시 거부(조용한 bounce 방지) — 손상 주소(이름 handle, @handle류)는 이제 발송 자체가 거부된다.

실측 검증 (전부 재현 — 라이브 무접촉, 스크래치 포트/데이터로만)

  • 컴파일: /tmp/메일v3.toau(서버) /tmp/smtp수신.toau(SMTP수신) 둘 다
CROWNY_STRICT=1 경고 0.
  • 스크래치 인스턴스: 서버 :18610(MAIL_DATA=스크래치 디렉토리), SMTP수신 :12525
(INGEST_URL=http://127.0.0.1:18610/api/mail/ingest). 라이브 :9610· /Users/ef/crowny-data/mail/은 프로세스도 안 띄웠고 mtime도 무변화 확인.
  • 자체 유닛테스트(4종, 스크래치 .한선 드라이버): base64 text/plain 우선 추출 PASS,
단일파트 quoted-printable 디코드 PASS, multipart/mixed 안 multipart/alternative 중첩 추출 PASS, 특수문자 JSON 이스케이프 PASS(python json.loads 유효성 확인). → 이 과정에서 시작하는가()==0 버그를 자체 테스트로 적발·즉시 수정.
  • 실제 gmail 스타일 MIME(python email.mime로 조립, boundary+text/plain·text/html
base64 파트, 따옴표 포함 본문)을 raw SMTP 소켓으로 :12525에 투입 → ingest 성공(mail-1) → /api/mail/read·/api/mail/list 응답이 평문 본문만 깔끔히 나오고(MIME 헤더/boundary 잔존 0건), python json.loads 유효 파싱 확인. fromIdkim.president.sk@gmail.com(완전 주소)로 나옴.
  • 버그② 3종 발송 테스트: (1) 구버그 재현형 "kim.president.sk @kim.president.sk"
400 거부, (2) 정상 완전주소 "kim.president.sk@gmail.com" → 큐 적재 성공, outq .emlTo: 헤더가 손상 없이 정확, (3) @handle(도메인 없음) → 400 거부.
  • 특수문자(따옴표+개행+백슬래시) 본문 실제 발송→읽기 왕복 테스트: 원문과 완전
일치(python assert 통과) — _JSON필드 입력 파서 수리와 출력 이스케이프가 함께 맞물려 정상 동작함을 확인.
  • 회귀: 내부 발송(kim→kps, waiting 게이트→gate/accept→inbox 편입 정상), 레거시 v2
/api/mail/inbox·/sent·search 정상, /api/chain/verify {"ok":true}, /health·/api/meta 정상.
  • 정적 게이트(앱v3.html): node --check로 스크립트 구문 유효, "맘 N개" 하드코딩
잔존 0건.
  • Node로 classifyRecipient/_이메일형식유효앱 로직 격리 재현 — 답장/수동입력/
"@yjin" 빠른추가 3경로 모두 완전 주소로 정규화됨을 확인.

관련 파일

  • /Users/ef/crowny-mail/크라우니메일-SMTP수신.한선 — MIME 파싱 함수군 추가,
_DATA완료처리() 수정, 계속(예약어) → 더돌기 치환.
  • /Users/ef/crowny-mail/크라우니메일서버.한선 — `_JSON문자열끝/_JSON문자열언이스케이프/
_JSON문자열이스케이프/_헥스digit값/_헥스4값/_이메일형식유효 신규, _JSON필드()` 수리, 메일상세v3/메일목록v3/메일목록/메일읽기/메일검색 출력 이스케이프 적용, fromId 완전주소화, /api/mail/send 형식 검증 추가.
  • /Users/ef/crowny-mail/pages/앱v3.htmlclassifyRecipient()/답장 프리필/
doSend() em 분리, _이메일형식유효앱() 신규 클라 검증.
  • 컴파일 산출: /tmp/메일v3.toau, /tmp/smtp수신.toau.

잔여 이슈 (후속 과제, 이번 스코프 밖)

  • Subject RFC2047 미디코드: gmail이 비ASCII 제목을 =?UTF-8?B?...?=로 인코딩해
보내는데, 이번 수리는 본문(body)만 다룸 — subject는 여전히 인코딩된 채로 저장/응답. 버그 리포트가 본문 문제만 지적했고 스코프 확대를 피해 보류. 필요 시 _RFC2047디코드() 추가로 대응 가능(Base64.한선의 UTF-8 재조립 로직 재사용).
  • SMTP 명령 대소문자 구분 버그(발견, 미수리): 크라우니메일-SMTP수신.한선
SMTP명령처리()명령 == "HELO"처럼 대문자만 매칭한다. 실사용 MTA(postfix 등)는 보통 대문자로 보내 문제 없지만, python smtplib처럼 소문자로 보내는 클라이언트는 전부 500 거부됨(재현: s.helo()500 Command not recognized). RFC5321상 명령어는 대소문자 무관이어야 함 — 이번 배정 범위(MIME/JSON/답장주소) 밖이라 손대지 않음, 별도 티켓 권장.
  • 외부 발송 다중 수신자 미지원: /api/mail/send받는이를 항상 단일 주소로
취급(콤마 join 문자열을 분리하지 않음) — UI가 여러 명에게 보내면 첫 항목 이후는 사실상 무시/오류. 이번 검증에서 _이메일형식유효()가 콤마+공백 섞인 값도 정직하게 거부하도록 만들어 "조용한 오발송"은 막았지만, 다중 수신자 지원 자체는 별도 작업.

크라우니코드

lookup MISS 2건(신규 로직: MIME 멀티파트 파싱, JSON 문자열 이스케이프/언이스케이프) — crownycode-learn.sh addfn_메일_MIME본문파싱, fn_메일_JSON이스케이프 학습 등록 완료.