docs: 앱 메일 읽기 화면의 구현하며 정한 것을 앱 디자인 README · flutter 스킬에 적는다

앱 디자인 README 「읽기」에 짜임 · 두 번째 정화 · 그림 한도 · 첨부 형식 막기 ·
붙잡힌 메일 · 알려진 한계 · 실기기 점검을, 「쓰기」에 mailto 숨은참조를
펼쳐 보일 것을 적는다. flutter 스킬 골격 표에 읽기 화면 · 받은 파일 줄과
인터셉터의 실패 본문 규칙을 더한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 22:26:09 +09:00
parent a37045a553
commit f4b1d0a206
2 changed files with 44 additions and 1 deletions
+3 -1
View File
@@ -25,8 +25,10 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 |
| **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `<queries>` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 |
| **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 |
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
| 받은 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `file_names.dart`(`safeFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다 |
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 |
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 |
| ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) |
| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 |
+41
View File
@@ -49,6 +49,7 @@
- 새 글(보드에 없던 상태): 키를 복사하지 못하면 "키를 복사하지 못했습니다. 키를 보고 인증 앱에 직접 입력해 주세요.", ③ 안내 "지금 비밀번호를 한 번 더 넣어 주세요." — 그 밖은 웹 로그인 틀의 글을 그대로 옮겼다.
- **알려진 한계**: 인증 앱에 다녀오는 사이 안드로이드가 앱을 정리하면 돌아왔을 때 새 키가 나온다(인증 앱에 먼저 넣은 항목은 지우고 다시 넣어야 한다 — 옛 키의 코드는 `AUTH_006`, 웹의 새로고침과 같은 결).
키 화면은 최근 앱 미리 보기에 찍히고(FLAG_SECURE 없음), 복사한 키는 클립보드에 남으며 안드로이드 13 이상은 복사 미리 보기에 보인다(Flutter 클립보드는 민감 표시를 못 한다 — 네이티브 채널이 든다). iOS 의 [인증 앱에 추가]는 iOS 를 붙일 때 여는 방식을 바꾼다.
### 메일 목록 (`App1MailList`)
- 위 막대: 서랍 열기 · 메일함 이름 · 찾기. 서랍은 메일함만(안 읽은 수는 회색 글 — 뱃지 없음), 탭 막대 위까지 화면 전체를 덮는다.
- 줄: 동그란 사진(40) · 보낸 사람 · 시각 / 제목 · 첨부 표시 / **미리 보기 한 줄**. 안 읽음은 굵게, 중요는 별.
@@ -68,16 +69,56 @@
다시 받은 쪽이 메일함 끝까지면 그 밖의 옛 메일은 다른 곳에서 지운 것으로 보고 남기지 않는다(웹은 남긴다 — 서버의 hasNext 는 끝까지 받았을 때만 거짓이다). 끝 표시는 보이는 동안 다음 쪽을 이어 부르고, 같은 커서가 되풀이되면 [다시 시도]로 멈춘다.
- 크기: 탭 막대의 고른 탭 알약은 Material 3 그대로 64×32(보드 56×32), 글자는 테마 역할로 맞췄다(12.5 → 12 · 13.5 → 13 · 14.5 → 15 · 17 → 18, 굵기 650 → 700 · 450 → 500). 탭 막대는 위 안전 영역을 빼고, 고르는 중 알림 줄은 시스템 내비게이션 위에 뜬다.
- 내 메일함 이름의 차례는 대소문자를 가리지 않는 비교다 — 영문 · 한글이 섞인 이름은 웹(`localeCompare('ko')`)과 다를 수 있다.
### 읽기 (`App1Reader`)
- 위 막대: 뒤로 · 삭제 · 더 보기(안 읽음 표시 · 중요 표시 · 이동 · 스팸 신고). 아래 행동 막대: 답장 · 전체 답장 · 전달.
- 머리(제목 · 보낸 사람 · 받는 사람 펼치기 · 시각)는 앱 화면, 본문은 앱 안 WebView — 스크립트를 끄고 앱이 감싼 문서의 CSP 로 바깥에 나가지 않는다. 외부 이미지는 안내 줄의 [이미지 보기]를 누르면 앱이 받아서 넣는다. 링크는 바깥 브라우저 · mailto 는 앱의 쓰기 화면.
- 첨부 목록 — 받는 중에는 진행(%)을 보인다.
- **구현하며 정한 것**(2026-11-06 — 보드에는 아직 없다)
- 짜임: 머리(제목 · 보낸 사람 · 받는 사람 · 시각 · 안내 줄 · 첨부)는 앱 화면으로 본문 위에 두고 남은 높이의 절반까지만 쓴다(넘으면 머리 안에서 스크롤).
본문 WebView 는 나머지를 다 쓰고 그 안에서 스크롤한다 — 스크립트를 꺼서 내용 높이를 잴 수 없다.
**첨부 목록은 본문 위**다(보드는 아래) — 본문이 WebView 안에서 스크롤하므로 아래에 두면 늘 화면 아래를 차지한다.
- 본문 글꼴은 기기 글꼴이다(CSP · 파일 접근 끄기로 WebView 가 Pretendard 를 쓰지 못한다). 크기 15 · 줄 간격 1.75 · 여백 16 / 20 / 24 는 보드대로, 기기 글자 크기 설정을 따르고, 앱이 어두운 테마여도 본문 바탕은 흰색이다.
- 앱이 한 번 더 정화한다(서버 정화 뒤 — 허용 목록으로 다시 쓴다). 길이 2,000,000 · 중첩 비용 8,000,000 을 넘으면 정화하지 않고 글자 본문과 서버의 생략 안내와 같은 글을 보인다.
서식 본문은 6,144자보다 짧고 중첩 비용이 5,000 이하일 때만 화면에서 바로, 그 밖은 다른 isolate 에서 정화한다(개발 PC 에서 바로 하는 것이 2ms 안쪽 — 휴대폰은 몇 배 느리다).
정화하는 동안 본문 자리 맨 위에 가는 줄을 둔다(화면 낭독 "본문을 불러오고 있습니다.").
- 링크: http · https 는 바깥 앱, mailto 는 쓰기 화면(채울 값 100 · 255 · 10,000자까지), 그 밖은 막는다. 열 앱이 없으면 "이 링크를 열 수 있는 앱이 없습니다."
- 외부 이미지: [이미지 보기]는 본문만 다시 받는다(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹과 같다). 그림은 앱이 받아 `data:` 로 넣는다 —
한 번에 4개, 그림 하나 5MB, 받는 합계 10MB, 서로 다른 그림 100개, 앞 바이트로 형식(PNG · JPEG · GIF · WebP)을 보고, 받지 못한 그림은 숨기고 알리지 않는다.
**문서에 싣는 그림 글은 나온 횟수까지 세어 1,000만 자에서 멈춘다** — 같은 그림을 수백 번 쓴 메일 한 통이 앱을 메모리 부족으로 끝내지 않게(2026-11-06 리뷰).
넘는 자리부터는 뒤의 그림을 모두 숨긴다 — 받은 그림이 다 보이지 않을 수 있다(5MB 그림 둘이면 하나만).
문서는 그림 없이 먼저 보이고 다 받은 뒤 한 번 다시 넣는다(그동안 머리 아래 진행 막대). 화면을 떠나면 받던 그림을 끊는다. 설정으로 처음부터 보이는 본문은 안내 줄 없이 바로 받는다.
- 읽음 · 중요: 열면 화면과 메일함 개수를 먼저 읽음으로 바꾸고 알리지 않고 보낸다(실패하면 알리지 않고 되돌린다). [안 읽음 표시]는 바꾼 뒤 목록으로 돌아간다.
[중요 표시]는 화면에 머물며 "중요 표시했습니다." · "중요 표시를 해제했습니다."(읽기 화면에 별이 없다). 응답은 그 요청이 바꾼 칸만 지금 값에 덮는다 — 그사이 바꾼 표시 · [이미지 보기]의 본문을 되돌리지 않게.
- 더 보기 메뉴는 상태에 따라 바뀐다(읽음 표시 ↔ 안 읽음 표시 · 중요 표시 ↔ 중요 해제 · 스팸 신고 ↔ 스팸 아님). 휴지통에만 든 메일은 삭제 단추가 빨간 [영구 삭제](확인 창 — 처음 초점 [취소])다.
삭제 · 이동 · 스팸은 목록의 여러 통 처리와 같은 규칙이고 성공하면 목록으로 돌아간다. **무엇을 하는 동안에는**(삭제 · 이동 · 스팸 · 읽음 표시 · 보내기 취소) 아래 막대와 본문 링크를 막고, 끝난 뒤에는 읽기 화면이 맨 위일 때만 닫는다.
- 붙잡힌 메일: 보내기 취소를 기다리는 메일은 "곧 보낼 메일입니다."(웹 읽기 화면에는 없는 줄), 예약 메일은 "{시각}에 보낼 메일입니다." + [보내기 취소] · [예약 취소] → 임시보관함으로 옮기고 목록으로.
이미 보냈으면(`MAIL_014`) 다시 받아 머문다. [삭제]는 확인 → 보내기 취소 → 휴지통이고, 확인 창을 연 채 보내기가 풀리면 창을 닫는다. 이동 · 스팸은 메뉴에서 빠지고 예약 메일에는 아래 막대가 없다(웹과 같다).
보낼 시각 + 2초에 다시 보고, 남아 있으면 5초마다 세 번 더 본다(서버 시각 — 목록과 같은 값).
- 첨부: [받기]는 앱의 임시 폴더에 받고(진행 %) 다 받으면 다른 앱으로 연다 — 같은 세션에서 다시 누르면 받지 않고 바로 연다. [받기 취소]는 쓰다 만 파일을 지우고, 화면을 떠나도 받기를 그만둔다.
- 열 형식은 이름의 확장자에서 먼저 정한다 — 보낸 쪽이 적은 형식은 확장자를 모를 때만 쓴다(웹은 늘 내려받기라 운영체제가 확장자로 고른다).
- **앱에서 열지 않는 파일**: 웹 문서(html · htm · xhtml · xht · shtml · mht · mhtml) · svg · 설치 파일(apk · apks · xapk) · 인증서 · 키 묶음(p12 · pfx · cer · crt · der · pem · p7b · p7c).
확장자나 형식이 하나라도 걸리면 받지 않고 "이 형식의 파일은 앱에서 열 수 없습니다. PC 웹메일에서 받아 주세요." — 다른 앱(브라우저)이 그 파일의 스크립트를 돌리거나 설치 · 등록 창을 띄우지 않게(2026-11-06 리뷰).
S/MIME 의 `smime.p7m` 도 여기에 걸린다. eml · xml 은 연다.
- 받은 파일은 앱을 띄울 때와 메일을 보는 직원이 바뀔 때(로그아웃 · 세션 끝 · 다른 직원) 지운다. 연 직후에는 다른 앱이 읽고 있을 수 있어 두고, 기기가 임시 폴더를 비우기도 한다.
- 대용량 첨부는 이름 · 크기와 "대용량 첨부는 앱에서 아직 받을 수 없습니다. 본문의 링크를 누르거나 웹메일에서 받아 주세요."만 보인다.
- 실패 글: "이 파일을 열 수 있는 앱이 없습니다. 파일을 여는 앱을 설치하거나 웹메일에서 받아 주세요." · "파일을 열지 못했습니다. 다시 받아 주세요." · "파일을 저장하지 못했습니다. 휴대폰의 저장 공간을 확인해 주세요.", 받는 도중 끊김은 `SYS_001`.
- 보드에 없던 상태를 더했다: 불러오는 뼈대, 받지 못함([다시 시도] · [목록으로]), 그림 받는 진행 막대, 본문 생략 · 잘림 안내 줄, 붙잡힌 메일 안내 줄, 대용량 첨부 줄.
- **알려진 한계**
- WebView 의 렌더러가 죽으면(메모리 부족 등) 앱도 함께 끝난다 — 플러그인(webview_flutter_android 4.12.0)이 그 소식을 넘기지 않는다. 위 그림 한도가 그 위험을 줄인다.
- 진행 막대의 화면 낭독 값은 숫자만 된다(Flutter 3.41) — 이름("… 받는 중")만 싣고 "받는 중 64%"는 화면 글로만 보인다.
- iOS 를 붙일 때: 파일 열기 채널(지금은 "파일을 열지 못했습니다."), WKWebView 의 링크 길게 누르기 미리 보기 끄기(`setAllowsLinkPreview(false)` — 미리 보기는 판정을 거치지 않고 그 주소를 연다).
- 실기기에서 볼 것: 넓은 표 뉴스레터(가로 스크롤 · 확대), 기기 글자 크기, 링크 · 길게 누르기(WebView 안에서 이동하지 않는지), 같은 그림을 여러 번 쓴 뉴스레터, 2MB 본문을 정화하는 시간,
첨부(PDF · 오피스 · hwp 가 한컴 뷰어에서 열리는지 · 막는 형식의 알림 · eml 을 어느 앱이 여는지 — 크롬이 MHTML 로 열면 스크립트가 꺼져 있는지), 파일 · content 접근이 꺼졌는지, 폴더 밖 경로는 열지 않는지,
TalkBack 읽는 차례(위 막대 → 머리 → 안내 → 첨부 → 본문 → 아래 막대), 뒤로 가기 몸짓, 로그아웃 뒤 받은 파일이 지워지는지.
### 쓰기 (`App1Compose`)
- 위 막대: 닫기 · 첨부 · 보내기. 받는 사람 · 제목은 웹 쓰기 화면처럼 줄 왼쪽에 이름(칸 안에서 떠오르는 꼴이 아니다). 받는 사람 추천은 맞은 글자를 굵게.
- 참조 · 숨은참조는 펼쳐서. **첨부는 제목 아래**(휴대폰은 본문이 길어지면 아래 첨부가 묻힌다) — 크기 · 빼기 · 올리는 중.
- 본문은 글만(서식 편집은 다음 판). **서명은 본문과 따로 된 칸에 [서명 빼기]만** — 서명 고치기는 웹에서. 답장의 원본 메일은 접혀 있고 펼쳐 볼 수 있다.
- 보낸 뒤에는 쓰기를 시작한 화면(읽기 · 목록)으로 돌아가고, 그 화면 아래에 "메일을 보냈습니다. [보내기 취소]"와 남은 시간 막대.
- 만들 때 지킬 것(2026-11-06 읽기 화면 리뷰): 읽기 화면의 mailto 링크로 채운 숨은참조가 있으면 숨은참조 칸을 펼쳐 보인다 — 링크가 숨은참조를 몰래 넣을 수 있다(RFC 6068 보안 고려).
### 새 메일 알림 (푸시)
- 알림 내용은 "새 메일 N통"만 — 보낸 사람 · 제목이 Google · Apple 서버를 거치지 않게. 누르면 메일 모듈의 받은편지함으로(알림에는 모듈 이름과 id 만 싣는다).