From 37ffcfca2c338e2a42ea4dd56dfcf2aac5e022a1 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 12 Nov 2026 00:28:54 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20=EB=A1=9C=EB=93=9C=EB=A7=B5?= =?UTF-8?q?=EC=97=90=2011=EC=9B=94=2012=EC=9D=BC=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EB=A5=BC=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 · 15 · 16단계 · 앱 서버 · 업데이트 내역을 11-12 0시에 운영에 올렸고 휴대폰 앱 1.0.0 을 나눠 주기 시작했다. 배포 뒤 받은 의견(앱 읽기 화면 · 쓰기 화면 입력칸)은 앱 1.0.1 로 고치는 중이다. Co-Authored-By: Claude Opus 5.5 --- docs/roadmap.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 86e86a3..58f4db6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3,13 +3,13 @@ 그룹웨어 전체의 작업 차례다. 단계 번호는 모듈마다 따로 센다 — 메일의 "N단계"는 API 계약([`docs/mail/api-contract.md`](./mail/api-contract.md))과 화면 디자인([`docs/design/webmail/README.md`](./design/webmail/README.md))의 "N단계"와 같다. 단계마다 **조사 → 캔버스(화면 보드) → 확정 → 구현 → 배포** 차례로 한다. -마지막 정리: 2026-11-10 +마지막 정리: 2026-11-12 ## 지금 -- **운영**: 메일 1~13단계. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔다(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로). -- **배포를 기다림**: 메일 14 · 15 · 16단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다. -- **개발 중**: 업데이트 내역 — 웹메일 · 휴대폰 앱의 설정에서 무엇이 새로 생기거나 바뀌었는지 읽는 게시판(글은 저장소의 글 파일, 셋째 주 배포에 함께). +- **운영**: 메일 1~16단계 · 휴대폰 앱 첫 판(안드로이드 1.0.0) · 업데이트 내역. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔고(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로), + 2026-11-12 0시에 14 · 15 · 16단계 · 앱 서버(앱 로그인 · 새 메일 알림 · 휴대폰 앱 받기) · 업데이트 내역을 운영에 올리고 휴대폰 앱을 나눠 주기 시작했다. +- **고치는 중**: 배포 뒤 받은 의견 — 앱 읽기 화면(본문 없이 첨부만 있는 메일) · 앱 쓰기 화면 입력칸. 앱 다음 판(1.0.1)으로 나간다. - **다음**: 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한). ## 1부 — 메일 @@ -29,9 +29,9 @@ | 11 | 대용량 첨부 — 링크로 보내기 · 조각 올리기 · 30일 보관 · 로그인 없는 내려받기 화면 · 악성 검사 | 운영 | | 12 | 계정 보안 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 로그인된 기기 | 운영 | | 13 | 전환 준비 — 새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 안내 · 운영 도구 · 백업 · 감시 · 휴지통 · 스팸 30일 | 운영 | -| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | main 에 합침 — 배포 기다림 | -| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | main 에 합침 — 배포 기다림 | -| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | main 에 합침 — 배포 기다림 | +| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | 운영(2026-11-12) | +| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | 운영(2026-11-12) | +| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 운영(2026-11-12) | - 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다. - 2026-11-09 계획에서 뺀 것: 수신 확인 · 사내 발송 취소(사내 받는 사람에게만 되고, 흔한 실수는 보내기 취소가 막는다 — 아래 후보로). @@ -48,8 +48,8 @@ | 판 | 내용 | 상태 | | --- | --- | --- | -| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | main 에 합침 — 서버 배포 뒤 직원 배포 | -| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | main 에 합침 — 서명 키 준비됨 | +| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | 운영 — 1.0.0(2026-11-12) | +| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | 운영 — 1.0.0 을 올림(2026-11-12) | | 다음 판 후보 | 주소록 · 대용량 첨부 · 필터 · 서식 편집 · 예약 메일 [지금 보내기] | 첫 판 반응을 보고 고른다 | ## 추후 후보 (필요해지면) -- 2.52.0 From 487760bd9b4a2d81c6416da66e05a3ac785a782f Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 12 Nov 2026 00:28:55 +0900 Subject: [PATCH 2/6] =?UTF-8?q?docs:=20=ED=9C=B4=EB=8C=80=ED=8F=B0=20?= =?UTF-8?q?=EC=95=B1=20APK=20=ED=81=AC=EA=B8=B0=EC=99=80=20apksigner=20?= =?UTF-8?q?=EC=9D=98=20Java=20=EB=A5=BC=20=EB=B0=94=EB=A1=9C=20=EC=A0=81?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 첫 release APK(1.0.0)는 기기 종류 셋(ABI)이 한 파일에 들어 64.4MB 였다 — 문서의 "판마다 30MB 남짓"을 고친다. 개발 PC 에 java 가 PATH 에 없으면 apksigner 가 결과 없이 끝나 서명 확인을 놓치므로 Android Studio 의 Java 를 먼저 잡는 줄을 flutter/README 사내 배포에 더한다. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/docker/SKILL.md | 4 ++-- flutter/README.md | 5 +++-- mail/README.md | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md index ec1a20d..14c3f39 100644 --- a/.claude/skills/docker/SKILL.md +++ b/.claude/skills/docker/SKILL.md @@ -106,7 +106,7 @@ sudo docker compose --env-file .env.production logs -f backend `nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`. - 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다 (기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다. - **휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 30MB 남짓)도 같은 줄들이다 — + **휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 65MB 안팎 — 기기 종류 셋(ABI)이 한 파일에 든다)도 같은 줄들이다 — 머리글을 대용량 받기와 같은 도우미(`backend/src/shared/utils/download-headers.ts` — `X-Accel-Buffering: no`)가 만든다. 정보 `GET /api/app-releases/android/latest`(JSON)는 받기 location 에 들지 않고, 공개 내려받기 server 에서는 그 밖의 `/api` 처럼 404 `RES_001` 이다. 머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다. @@ -223,7 +223,7 @@ sudo docker compose --env-file .env.production logs -f backend - 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다. 필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다). - **업로드 파일은 named volume `_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다, - 휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 30MB 남짓 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 — + 휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 65MB 안팎 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 — `sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의 `/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다. 백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다. diff --git a/flutter/README.md b/flutter/README.md index af3fa94..9a4f53d 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -137,8 +137,9 @@ flutter build apk --release Copy-Item build\app\outputs\flutter-apk\app-release.apk "$env:USERPROFILE\Desktop\ARA-SYSTEM-1.0.1.apk" # 이름은 ARA-SYSTEM-<버전>.apk ``` -- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴). -- 올리기 전에 본다: +- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64 — 그래서 1.0.0 이 64.4MB 다). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴). +- 올리기 전에 본다 — `apksigner` 는 Java 로 돈다. PATH 에 `java` 가 없으면 결과 없이 끝나니(2026-11-11 개발 PC) 먼저 Android Studio 의 Java 를 잡는다: + `$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:Path = "$env:JAVA_HOME\bin;$env:Path"` - 우리 키로 서명했는지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\apksigner.bat" verify --print-certs <파일>` 의 `certificate DN` 이 keytool 에 넣은 이름이다(`CN=Android Debug` 면 debug 키다). - 파일 안의 판이 파일 이름과 같은지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\aapt2.exe" dump badging <파일> | Select-String "^package:"` 의 `versionName` 이 파일 이름의 버전, `versionCode` 가 `pubspec.yaml` 의 `+` 뒤 숫자다(예 `versionCode='2' versionName='1.0.1'`). diff --git a/mail/README.md b/mail/README.md index fcb918b..cd70fd3 100644 --- a/mail/README.md +++ b/mail/README.md @@ -1809,7 +1809,7 @@ mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a | --- | --- | --- | | 메일 엔진 `_mail-config` · `_mail-data` | 볼륨 (읽기 전용으로 붙인다) | RocksDB — 메일 · 계정 · 설정 · DKIM 키 · 인증서 전체. `config.json` 이 없으면 bootstrap 모드로 뜬다 | | DB | `pg_dump -Fc` (멈추지 않는다) | 로그인 세션 · 서명 · 설정 · 대용량 첨부 기록 등. 덤프를 `pg_restore -l` 로 읽어 본 뒤 올린다 | -| 업로드 `_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` · 휴대폰 앱 APK(`app-releases/` — 판마다 30MB 남짓, 지우는 명령이 없어 올린 판이 모두 남는다) | +| 업로드 `_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` · 휴대폰 앱 APK(`app-releases/` — 판마다 65MB 안팎(1.0.0 이 64.4MB — 기기 종류 셋(ABI)이 한 파일에 든다), 지우는 명령이 없어 올린 판이 모두 남는다) | | env 파일 | `.env.production` · `backend/.env.production` | 비밀이 든 파일 — 저장소가 restic 비밀번호로 암호화된다. 새 서버를 되살릴 때 필요하다 | | 받지 않는 것 | — | 대용량 첨부 `_files`(30일짜리 전송물 — 기록은 DB 에 있어 복원하면 바이트가 없는 파일은 "찾을 수 없음"), 악성 검사 서명 `_clamav-db`(다시 받는다), 리졸버 · frontend(상태 없음), 로그 | -- 2.52.0 From 94ec659b785d4844b0a596adfa29981e38140c89 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 12 Nov 2026 08:10:34 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(flutter):=20=EB=B3=B8=EB=AC=B8=20?= =?UTF-8?q?=EC=97=86=EC=9D=B4=20=EC=B2=A8=EB=B6=80=EB=A7=8C=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EB=A9=94=EC=9D=BC=EC=97=90=EC=84=9C=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=20=EC=95=84=EB=9E=98=EA=B0=80=20?= =?UTF-8?q?=EB=B9=84=EA=B3=A0=20=EC=B2=A8=EB=B6=80=EA=B0=80=20=EC=9E=98?= =?UTF-8?q?=EB=A6=AC=EB=8D=98=20=EA=B2=83=EC=9D=84=20=EA=B3=A0=EC=B9=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 읽기 화면은 머리(제목 · 보낸 사람 · 안내 · 첨부)가 남은 높이의 절반까지만 쓰고 나머지는 본문 WebView 였다. 본문이 비면 아래 절반이 빈 칸으로 남고 첨부가 많으면 위 절반 안에서 잘렸다(11-12 배포 뒤 사용자 보고). 정화한 본문에 보이는 것(글자 · 주소가 남은 그림 · hr · li · details · q)이 없으면 상태 bodyEmpty 로 본문 칸을 두지 않고 머리가 화면 전체를 한 스크롤로 쓴다. 판정은 정화와 같은 길에서 내고(큰 본문은 isolate), 아래 막대가 없는 예약 메일은 머리 스크롤에 시스템 아래 여백을 더한다. 본문이 있는 메일은 그대로다. Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/utils/visible_text.dart | 56 +++++ .../lib/features/mail/domain/mail_html.dart | 69 ++++-- .../pages/message_reader_page.dart | 47 +++- .../providers/message_reader_controller.dart | 3 + .../providers/message_reader_state.dart | 7 + .../presentation/widgets/mail_body_view.dart | 1 + .../test/core/utils/visible_text_test.dart | 111 +++++++++ .../test/features/mail/mail_html_test.dart | 135 +++++++++++ .../mail/message_reader_page_test.dart | 227 ++++++++++++++++++ 9 files changed, 625 insertions(+), 31 deletions(-) create mode 100644 flutter/lib/core/utils/visible_text.dart create mode 100644 flutter/test/core/utils/visible_text_test.dart diff --git a/flutter/lib/core/utils/visible_text.dart b/flutter/lib/core/utils/visible_text.dart new file mode 100644 index 0000000..e7d151c --- /dev/null +++ b/flutter/lib/core/utils/visible_text.dart @@ -0,0 +1,56 @@ +/// 글에 보이는 글자가 있는가 — 순수 함수 (모듈 공통 — 메일 본문이 비었는지 등). +/// +/// 아래 글자만 있는 글은 보이는 것이 없다(빈 글이다): +/// - 공백: Dart `trim()` 이 지우는 공백 전부 — 빈칸 · 탭 · 줄바꿈 · nbsp · 전각 빈칸 · 줄 · 문단 구분 · BOM 등 +/// - 제어 문자: C0 · DEL · C1 +/// - 보이지 않는 글자: 유니코드의 Default_Ignorable_Code_Point — 제로폭 빈칸 · 이음(ZWNJ · ZWJ · WORD JOINER) · 방향 표시(LRM · RLM · 끼워 넣기 · 격리) · +/// soft hyphen · 이체 선택자 · 한글 채움 문자(U+115F · U+1160 · U+3164 · U+FFA0) · 태그 글자 등 +/// - 점자 빈칸(U+2800) — 글자이지만 빈칸으로 그려진다 +library; + +/// [text] 에 보이는 글자가 하나라도 있는가 — 앞에서부터 보고 처음 보이는 글자에서 멈춘다(길이에 비례) +bool hasVisibleText(String text) { + for (final int rune in text.runes) { + if (!_isInvisible(rune)) return true; + } + return false; +} + +/// 보이지 않는 글자인가 — 위 머리말의 목록 +bool _isInvisible(int rune) { + // C0 제어 문자 · 빈칸 + if (rune <= 0x20) return true; + // 그 밖의 ASCII 는 모두 보인다 + if (rune < 0x7F) return false; + // DEL · C1 제어 문자(NEL 포함) · nbsp + if (rune <= 0xA0) return true; + for (final (int first, int last) in _invisibleRanges) { + if (rune < first) return false; + if (rune <= last) return true; + } + return false; +} + +/// 보이지 않는 글자의 범위 — (처음, 끝) 끝 포함 · 작은 차례대로(U+00A0 까지는 위에서 따로 본다) +const List<(int, int)> _invisibleRanges = <(int, int)>[ + (0x00AD, 0x00AD), // soft hyphen + (0x034F, 0x034F), // 글자 묶음 이음(CGJ) + (0x061C, 0x061C), // 아랍 글자 방향 표시 + (0x115F, 0x1160), // 한글 초성 · 중성 채움 문자 + (0x1680, 0x1680), // 오검 빈칸 + (0x17B4, 0x17B5), // 크메르의 소리 없는 모음 + (0x180B, 0x180F), // 몽골 이체 선택자 · 모음 구분 + (0x2000, 0x200F), // 너비가 다른 빈칸 · 제로폭 빈칸 · 이음 · 방향 표시 + (0x2028, 0x202F), // 줄 · 문단 구분 · 방향 끼워 넣기 · 좁은 nbsp + (0x205F, 0x206F), // 수학 빈칸 · WORD JOINER · 보이지 않는 연산자 · 방향 격리 + (0x2800, 0x2800), // 점자 빈칸 + (0x3000, 0x3000), // 전각 빈칸 + (0x3164, 0x3164), // 한글 채움 문자 + (0xFE00, 0xFE0F), // 이체 선택자 + (0xFEFF, 0xFEFF), // BOM(제로폭 nbsp) + (0xFFA0, 0xFFA0), // 반각 한글 채움 문자 + (0xFFF0, 0xFFF8), // 보이지 않게 정해 둔 빈 자리 + (0x1BCA0, 0x1BCA3), // 속기 서식 제어 + (0x1D173, 0x1D17A), // 악보 서식 제어 + (0xE0000, 0xE0FFF), // 태그 글자 · 이체 선택자 보충 +]; diff --git a/flutter/lib/features/mail/domain/mail_html.dart b/flutter/lib/features/mail/domain/mail_html.dart index 694f160..da2bbdc 100644 --- a/flutter/lib/features/mail/domain/mail_html.dart +++ b/flutter/lib/features/mail/domain/mail_html.dart @@ -11,8 +11,11 @@ /// /// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 — /// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다. +/// +/// 정화하며 본문에 보이는 것이 있는지도 함께 본다([SanitizedMailBody.hasVisibleContent]) — 없으면 읽기 화면이 본문 칸을 그리지 않는다. library; +import 'package:arasystem_app/core/utils/visible_text.dart'; import 'package:arasystem_app/features/mail/domain/mail_images.dart'; import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:html/dom.dart' as dom; @@ -67,6 +70,11 @@ const Set _droppedWithContent = { /// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다 const Set _voidTags = {'br', 'col', 'hr', 'img', 'wbr'}; +/// 남기는 태그 가운데 글 없이도 그려지는 것 — 가로줄 · 목록 표시(• · 1.) · 펼침 표시(▶ 와 기본 이름) · 따옴표. +/// 그림(`img`)은 주소가 남았을 때만 보인다(주소를 지운 그림은 문서가 숨긴다 — [buildMailDocument]) — 따로 본다. +/// 모양 속성(style · bgcolor · border)만으로 그린 상자 · 선 · 바탕과 빈 인용의 왼쪽 막대는 보이는 것으로 치지 않는다 — 글도 그림도 없는 꾸밈뿐이다 +const Set _drawnWithoutText = {'hr', 'li', 'details', 'q'}; + /// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다 const Set _globalAttributes = { 'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', // @@ -109,6 +117,7 @@ class SanitizedMailBody { const SanitizedMailBody({ required this.fragment, required this.proxiedTokens, + required this.hasVisibleContent, this.htmlOmitted = false, }); @@ -118,6 +127,12 @@ class SanitizedMailBody { /// 받아 넣을 중계 그림의 표 — 문서 차례 · 겹치지 않게 · [kMaxProxiedImages] 까지(외부 이미지를 보일 때만) final List proxiedTokens; + /// 본문에 보이는 것이 있다 — 공백 · 보이지 않는 글자가 아닌 글자([hasVisibleText]), 주소가 남은 그림, 글 없이도 그려지는 태그 + /// (가로줄 · 목록 표시 · 펼침 표시 · 따옴표). 거짓이면 읽기 화면이 본문 칸(WebView)을 그리지 않는다. + /// + /// 중계 그림은 받기 전에도 보이는 것으로 친다 — 하나도 받지 못하면 빈 본문 칸이 남는다(본문을 다시 보지 않는다) + final bool hasVisibleContent; + /// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다 final bool htmlOmitted; } @@ -130,7 +145,10 @@ String? displayedHtmlBody(String? htmlBody) => /// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다). /// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다. /// [textHighlights] 는 글 본문의 검색에서 맞은 곳(검색 결과에서 연 메일 — 14단계) — 글 본문을 보일 때만 `` 로 넣는다 -/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다) +/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다). +/// +/// 고른 본문에 보이는 것이 있는지([SanitizedMailBody.hasVisibleContent])도 함께 낸다 — 고르는 규칙은 그대로다(서식 본문이 비어 보여도 +/// 글자 본문으로 바꾸지 않는다 — 웹 `displayedHtmlBody` 와 같은 본문을 보인다) SanitizedMailBody mailBodyFragment({ required String? htmlBody, required String? textBody, @@ -162,6 +180,7 @@ SanitizedMailBody _textFragment( fragment: '
${highlightedTextHtml(textBody ?? '', highlights)}
', proxiedTokens: const [], + hasVisibleContent: hasVisibleText(textBody ?? ''), htmlOmitted: htmlOmitted, ); @@ -316,15 +335,22 @@ const Set _htmlVoidTags = { /// 허용 목록 밖의 태그는 태그만 지우고 글은 남긴다(`` · `
` · `