feat(flutter): 메일 읽기 화면을 만든다 (App1Reader)

머리는 앱 화면, 본문은 스크립트를 끈 WebView 에 앱이 감싼 CSP 문서로
보인다. 받은 HTML 은 서버 정화 뒤 앱이 한 번 더 허용 목록으로 다시 쓰고,
외부 이미지는 [이미지 보기] 뒤 앱이 Dio 로 받아 data: 로 넣는다(토큰이
WebView 에 가지 않는다).

- 읽음 · 중요 · 이동 · 스팸 · 삭제 · 영구 삭제, 붙잡힌 메일의 보내기 취소 ·
  예약 취소는 목록과 같은 규칙. 응답은 그 요청이 바꾼 칸만 지금 값에 덮는다
- 첨부는 앱 임시 폴더(cacheDir/downloads)에 받아 FileProvider + ACTION_VIEW
  로 연다(파일 여는 플러그인 없이). 열 형식은 확장자 먼저, 웹 문서 · svg ·
  설치 파일 · 인증서는 받지 않는다
- 응답 인터셉터가 바이트 · 스트림 실패 본문(64KB 까지)에서 코드를 읽고,
  뜻밖의 실패에도 handler.next 로 끝낸다
- 리뷰 H1: 같은 그림을 수백 번 쓴 메일이 문서를 GB 단위로 키우던 것 —
  문서에 싣는 그림 글을 나온 횟수까지 세어 10M 자에서 멈춘다
- 리뷰 M1: 짧고 깊은 본문을 화면 isolate 에서 정화하던 것 — 길이와 중첩
  비용으로 고르고(6,144자 · 비용 5,000), 그 밖은 Isolate.run
- 새 의존성: webview_flutter 4.14.1 · webview_flutter_android 4.12.0 ·
  html 0.15.7(+ csslib 1.0.2) — 배포 2주 넘음. path_provider · mime 은
  lock 에 있던 판을 직접 의존으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 22:26:09 +09:00
parent ef23afc94c
commit a37045a553
51 changed files with 8325 additions and 48 deletions
+11 -2
View File
@@ -56,13 +56,22 @@ flutter build appbundle --release # Play 에 올리는 꼴 — 아래 서명
- **Android 백업을 껐다**(`android:allowBackup="false"` · `res/xml/data_extraction_rules.xml`) — 보안 저장소의 값은 이 기기의 Keystore 키로 암호화되어 다른 기기로 복원되면 읽지 못하고, 첫 실행 표시까지 복원되면 남은 토큰 지우기가 돌지 않는다.
- **iOS Keychain 은 앱을 지워도 남는다** — 일반 저장소(`shared_preferences`)의 첫 실행 표시가 없으면 앱을 띄우기 전에 보안 저장소를 모두 지운다(`core/storage/token_store.dart` 의 `prepare`).
- 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각)로 남은 시간을 잰다.
- **받은 메일 본문(WebView)**: 앱이 한 번 더 정화한 문서(`features/mail/domain/mail_html.dart` — 허용 목록 · CSP `default-src 'none'; img-src data:`)만 넣고,
스크립트 · 파일 · content 주소 접근을 끄고, 주 틀의 탐색은 모두 막는다(링크는 바깥 브라우저 · mailto 는 쓰기 화면). 아래 틀(iframe)의 탐색은 안드로이드가 판정에 넘기지 않는다 —
아래 틀은 정화(iframe 을 내용째 버림)와 CSP(`default-src 'none'` — frame-src)가 막는다. 외부 이미지는 앱이 Dio(Bearer)로 받아 `data:` 로 넣는다 — 토큰이 WebView 에 가지 않는다.
문서에 싣는 그림 글은 같은 그림이 여러 번 나오면 나온 만큼 세어 10MB 에서 멈춘다.
- **받은 첨부**: 앱 임시 폴더(`cacheDir`)의 `downloads/` 에 받고, 다른 앱으로 열 때는 FileProvider(`${applicationId}.downloads` — 그 폴더만)의 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다(`MainActivity.kt` 의 파일 채널).
앱을 띄울 때(지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때 모두 지운다. 파일 이름은 경로 · 제어 · 방향 제어 글자를 지우고 줄인다(`core/files/file_names.dart`).
열 형식은 정리한 이름의 확장자에서 먼저 정하고(보낸 쪽 형식은 확장자를 모를 때만), 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 받지 않고 알린다(`core/files/file_types.dart`).
고르는 창(`createChooser`)으로 열지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다.
## 나중에 넣을 때 주의
- **build_runner(freezed · json_serializable · riverpod_generator)**: 같은 폴더의 `lib/core/api/error_lexicon.g.dart`(백엔드 레지스트리에서 만든 사전 — 직접 고치지 않는다)를 build_runner 가 자기 출력으로 보고 지울 수 있다.
넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고, 처음 한 번 돌린 뒤 사전이 그대로인지(`git diff`) 확인한다.
- **첨부 받기(스트림 · 바이트 응답)**: `responseType` 이 `bytes` · `stream` 이면 실패 응답의 본문도 JSON 으로 풀리지 않아 `toApiException` 이 HTTP 상태로만 코드를 정한다(`FILE_007` · `FILE_008` 같은 코드를 잃는다).
받기를 만들 때 실패 본문(작다)을 끝까지 읽어 UTF-8 · JSON 으로 풀어 표준 실패의 `error.code` 를 꺼낸 뒤 코드를 정한다. 성공 본문은 지금처럼 풀지 않고 그대로 넘긴다(시험이 지킨다).
- **첨부 받기(스트림 · 바이트 응답)**: 실패 응답의 본문은 `ResponseInterceptor.onError` 가 64KB 까지 읽어 UTF-8 · JSON 으로 풀고 표준 실패의 `error.code` 를 꺼낸다
(`decodeRawErrorBody` — 그 전에는 HTTP 상태로만 정해 `FILE_007` · `FILE_008` 같은 코드를 잃었다). 성공 본문은 풀지 않고 그대로 넘긴다(시험이 지킨다).
받는 도중 끊기거나 조각 사이 받기 한도(`receiveTimeout` — Dio 가 스트림에 싣는다)를 넘기면 응답이 이미 인터셉터를 지나 아무도 알리지 않으므로 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`).
## 라이선스