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
+68 -2
View File
@@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:dio/dio.dart';
@@ -41,12 +44,75 @@ class ResponseInterceptor extends Interceptor {
);
}
/// 실패 — 코드를 정한다. 바이트 · 스트림 응답(첨부 받기 · 그림)의 실패 본문은 JSON 으로 풀리지 않은 채 오므로
/// 먼저 읽어(작다 — [kMaxErrorBodyBytes] 까지) 표준 실패의 `error.code` 를 꺼낸다. 성공 본문은 건드리지 않는다.
///
/// 무슨 일이 있어도 `handler.next` 로 끝낸다(인증 인터셉터와 같은 규칙) — 비동기 처리기가 던지면 요청이 끝나지 않고 매달린다
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
if (err.error is ApiException) {
handler.next(err);
return;
}
handler.next(err.copyWith(error: toApiException(err)));
DioException failure;
try {
final Response<Object?>? response = err.response;
if (response != null) {
final Map<String, Object?>? decoded = await decodeRawErrorBody(
response.data,
);
if (decoded != null) response.data = decoded;
}
failure = err.copyWith(error: toApiException(err));
} on Object {
// 실패 본문을 읽다 뜻밖에 실패했다 — 코드는 최종 폴백으로 정하고 다음 인터셉터로 넘긴다
failure = err.copyWith(
error: ApiException.fromCode(
kFallbackCode,
statusCode: err.response?.statusCode,
),
);
}
handler.next(failure);
}
}
/// 바이트 · 스트림 응답의 실패 본문을 읽는 최대 크기 — 표준 실패 본문은 1KB 안팎이다
const int kMaxErrorBodyBytes = 64 * 1024;
/// 바이트(`List<int>`) · 스트림(`ResponseBody`) 실패 본문 → JSON 객체. 이미 풀린 본문 · 너무 큼 · JSON 이 아님은 null.
/// 스트림은 끝까지(한도 안) 읽어 연결을 놓아 준다
Future<Map<String, Object?>?> decodeRawErrorBody(Object? data) async {
final List<int>? bytes;
if (data is ResponseBody) {
bytes = await _readLimited(data.stream);
} else if (data is List<int>) {
bytes = data.length <= kMaxErrorBodyBytes ? data : null;
} else {
return null;
}
if (bytes == null || bytes.isEmpty) return null;
try {
final Object? json = jsonDecode(utf8.decode(bytes, allowMalformed: true));
return json is Map<String, Object?> ? json : null;
} on FormatException {
return null;
}
}
Future<List<int>?> _readLimited(Stream<List<int>> stream) async {
final BytesBuilder builder = BytesBuilder(copy: false);
try {
await for (final List<int> chunk in stream) {
builder.add(chunk);
// 한도를 넘으면 그만 읽는다(구독을 끊어 연결을 놓는다) — 코드는 상태로 정한다
if (builder.length > kMaxErrorBodyBytes) return null;
}
} on Object {
return null;
}
return builder.takeBytes();
}
@@ -0,0 +1,215 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:arasystem_app/core/files/file_names.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
/// 받은 파일을 두는 폴더 이름 — 앱 임시 폴더(Android `cacheDir`) 아래.
/// **짝: `android/app/src/main/res/xml/downloaded_file_paths.xml` 의 `cache-path` 와 `MainActivity.kt` 의 `DOWNLOADS_DIR`** — 바꾸면 셋 다
const String kDownloadedFilesFolder = 'downloads';
/// 받은 파일 하나의 자리 — 받는 동안 조각을 이어 쓰고, 다 받으면 닫는다
abstract class DownloadTarget {
/// 기기 안의 경로 — 다른 앱으로 열 때 쓴다(로그 · 알림에 싣지 않는다)
String get path;
/// 받은 조각을 이어 쓴다
void add(List<int> chunk);
/// 다 받았다 — 쓴 것을 마저 내보내고 닫는다
Future<void> close();
/// 받다 말았다(실패 · 취소) — 닫고 지운다
Future<void> discard();
}
/// 받은 파일을 잠깐 두는 곳 — 앱이 다른 앱에 파일을 건네려고 둔다(모듈 공통).
///
/// 정리하는 때: 앱을 띄울 때(main — 지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때(로그아웃 · 세션 끝 · 다른 직원) ·
/// 기기가 저장 공간이 모자랄 때(안드로이드가 임시 폴더를 비운다). 다른 앱이 아직 읽는 중일 수 있어 연 직후에는 지우지 않는다.
abstract class DownloadedFileStore {
/// 새 파일 자리 — 받을 때마다 새 폴더 하나(같은 이름이어도 덮지 않는다). 이름은 [safeFileName] 으로 정리한다
Future<DownloadTarget> create(String fileName);
/// 받아 둔 파일이 아직 있는가 (기기가 임시 폴더를 비웠을 수 있다)
Future<bool> exists(String path);
/// 모두 지운다
Future<void> purge();
}
/// 받은 파일을 두는 곳 — 앱 임시 폴더의 [kDownloadedFilesFolder] 아래
final Provider<DownloadedFileStore> downloadedFileStoreProvider =
Provider<DownloadedFileStore>((Ref ref) => CacheDirFileStore());
/// 앱 임시 폴더(path_provider `getTemporaryDirectory` — Android `cacheDir`)를 쓰는 [DownloadedFileStore].
///
/// 지우기와 새 자리 만들기는 부른 차례대로 하나씩 한다 — 앱을 띄울 때의 정리가 막 만든 자리를 지우지 않게.
class CacheDirFileStore implements DownloadedFileStore {
CacheDirFileStore({
Future<Directory> Function() baseDirectory = getTemporaryDirectory,
Random? random,
}) : _baseDirectory = baseDirectory,
_random = random ?? Random.secure();
final Future<Directory> Function() _baseDirectory;
final Random _random;
Future<void> _queue = Future<void>.value();
/// 받을 때마다 만드는 폴더 이름의 길이(16진수)
static const int _folderNameLength = 16;
Future<T> _serial<T>(Future<T> Function() task) {
final Future<T> next = _queue.then((_) => task());
_queue = next.then<void>((_) {}, onError: (Object _) {});
return next;
}
/// 받은 파일 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) 저장하지 못한 것으로 다룬다([FileSystemException])
Future<Directory> _root() async {
final Directory base;
try {
base = await _baseDirectory();
} on FileSystemException {
rethrow;
} on Exception {
throw const FileSystemException('임시 폴더를 찾지 못했습니다');
}
return Directory('${base.path}/$kDownloadedFilesFolder');
}
@override
Future<DownloadTarget> create(String fileName) => _serial(() async {
final Directory root = await _root();
final Directory folder = Directory('${root.path}/${_folderName()}');
await folder.create(recursive: true);
final File file = File('${folder.path}/${safeFileName(fileName)}');
return _FileTarget(file, file.openWrite());
});
@override
Future<bool> exists(String path) => File(path).exists();
@override
Future<void> purge() => _serial(() async {
try {
final Directory root = await _root();
if (await root.exists()) await root.delete(recursive: true);
} on FileSystemException {
// 임시 폴더를 모르거나 다른 앱이 열고 있는 등 — 다음 정리 · 기기가 지운다
}
});
String _folderName() => List<String>.generate(
_folderNameLength,
(_) => _random.nextInt(16).toRadixString(16),
).join();
}
class _FileTarget implements DownloadTarget {
_FileTarget(this._file, this._sink) {
// 쓰기가 실패하면(폴더가 지워짐 · 저장 공간) 그 실패는 close · discard 가 받는다 — 그 전에 잡히지 않은 오류로 알려지지 않게
unawaited(_sink.done.then<void>((_) {}, onError: (Object _) {}));
}
final File _file;
final IOSink _sink;
bool _closed = false;
@override
String get path => _file.path;
@override
void add(List<int> chunk) => _sink.add(chunk);
/// 닫기가 남은 쓰기를 마저 내보낸다 — `flush` 는 부르지 않는다(쓰기가 이미 실패한 IOSink 의 flush 는 끝나지 않는다.
/// close 는 그 실패를 [FileSystemException] 으로 돌려준다)
@override
Future<void> close() async {
if (_closed) return;
_closed = true;
await _sink.close();
}
@override
Future<void> discard() async {
if (!_closed) {
_closed = true;
try {
await _sink.close();
} on FileSystemException {
// 지우는 것이 목적이다
}
}
try {
await _file.parent.delete(recursive: true);
} on FileSystemException {
// 이미 없거나 다른 앱이 열었다 — 다음 정리 · 기기가 지운다
}
}
}
/// 받은 파일을 다른 앱으로 연 결과
enum FileOpenResult {
/// 다른 앱을 띄웠다(또는 고르는 창)
opened,
/// 이 형식을 열 앱이 없다
noApp,
/// 파일이 없다(기기가 임시 폴더를 비웠다)
notFound,
/// 그 밖의 실패(이 기기에서 열 수 없음 — iOS 는 아직 없다)
failed,
}
/// 받은 파일을 다른 앱으로 연다 — 시험은 덮어쓴다
abstract class FileOpener {
Future<FileOpenResult> open({required String path, required String mimeType});
}
final Provider<FileOpener> fileOpenerProvider = Provider<FileOpener>(
(Ref ref) => const PlatformFileOpener(),
);
/// 안드로이드 `ACTION_VIEW`(`MainActivity.kt` 의 파일 채널) — 앱의 FileProvider 가 [kDownloadedFilesFolder] 안의 파일만 건넨다.
///
/// 패키지를 더하지 않고 앱 안에 둔 까닭: 파일 여는 플러그인은 앱에 저장소 · 사진 권한(Play 의 사진 · 동영상 권한 정책)과
/// 기기 전체를 가리키는 FileProvider 경로를 더하고, 공유(share) 플러그인은 "열기"가 아니라 "보내기" 창이다.
/// 이 채널은 받은 파일 폴더 한 곳만 읽게 건넨다(읽기 권한 1회 — `FLAG_GRANT_READ_URI_PERMISSION`). iOS 는 붙일 때 만든다
class PlatformFileOpener implements FileOpener {
const PlatformFileOpener();
/// 짝: `MainActivity.kt` 의 `FILES_CHANNEL` · `OPEN_METHOD`
static const MethodChannel channel = MethodChannel(
'kr.it.arasystem.app/files',
);
static const String openMethod = 'open';
@override
Future<FileOpenResult> open({
required String path,
required String mimeType,
}) async {
try {
final String? result = await channel.invokeMethod<String>(
openMethod,
<String, Object?>{'path': path, 'type': mimeType},
);
return switch (result) {
'opened' => FileOpenResult.opened,
'noApp' => FileOpenResult.noApp,
'notFound' => FileOpenResult.notFound,
_ => FileOpenResult.failed,
};
} on PlatformException {
return FileOpenResult.failed;
} on MissingPluginException {
return FileOpenResult.failed;
}
}
}
+71
View File
@@ -0,0 +1,71 @@
/// 받은 파일을 기기에 둘 때의 이름 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈).
///
/// 이름은 보낸 쪽이 정한 글이라 믿지 않는다 — 경로 문자 · 제어 문자 · 방향 제어 문자를 지우고 길이를 줄인다
/// (서버의 첨부 이름 정리 `attachment.policy.ts` 와 같은 결 — 확장자는 남긴다). 저장 경로는 앱이 만든 폴더 안 한 칸뿐이다.
library;
import 'dart:convert';
/// 이름이 비면 쓰는 이름
const String kFallbackFileName = '첨부파일';
/// 저장 이름의 최대 UTF-8 바이트 — 안드로이드 파일 시스템(ext4 · f2fs)의 한 칸 한도(255)보다 넉넉히 작게
const int kMaxFileNameBytes = 200;
/// 남기는 확장자의 최대 길이 (점 뒤)
const int _maxExtensionLength = 16;
/// 지우는 글자 — 제어 문자(C0 · DEL · C1)
bool _isControl(int rune) => rune <= 0x1F || (rune >= 0x7F && rune <= 0x9F);
/// 지우는 글자 — 방향 제어 · 보이지 않는 글자(오른쪽에서 왼쪽 표시로 "…fdp.exe" 를 "…exe.pdf" 처럼 보이게 하는 속임 이름)
bool _isInvisibleFormat(int rune) =>
rune == 0x061C ||
(rune >= 0x200B && rune <= 0x200F) ||
(rune >= 0x202A && rune <= 0x202E) ||
(rune >= 0x2060 && rune <= 0x2069) ||
rune == 0xFEFF;
/// 밑줄로 바꾸는 글자 — 경로 구분 · 다른 시스템이 이름에 받지 않는 글자
const String _replacedCharacters = r'/\:*?"<>|';
/// 기기에 둘 파일 이름 — 비거나 `.` · `..` 뿐이면 [fallback]
String safeFileName(String name, {String fallback = kFallbackFileName}) {
final StringBuffer cleaned = StringBuffer();
for (final int rune in name.runes) {
if (_isControl(rune) || _isInvisibleFormat(rune)) continue;
final String char = String.fromCharCode(rune);
cleaned.write(_replacedCharacters.contains(char) ? '_' : char);
}
// 앞뒤 공백 · 점을 지운다 — 앞의 점은 숨은 파일, 끝의 점 · 공백은 다른 시스템이 받지 않는다
String result = cleaned.toString().trim();
result = result.replaceAll(RegExp(r'^[.\s]+|[.\s]+$'), '');
if (result.isEmpty) return fallback;
return _fitBytes(result, kMaxFileNameBytes);
}
/// UTF-8 [maxBytes] 안으로 줄인다 — 확장자(점 뒤 짧은 영문 · 숫자)는 남기고 앞부분을 글자 단위로 자른다
String _fitBytes(String name, int maxBytes) {
if (utf8.encode(name).length <= maxBytes) return name;
final int dot = name.lastIndexOf('.');
final String extension =
dot > 0 &&
name.length - dot - 1 <= _maxExtensionLength &&
RegExp(r'^[A-Za-z0-9]+$').hasMatch(name.substring(dot + 1))
? name.substring(dot)
: '';
final String base = extension.isEmpty ? name : name.substring(0, dot);
final int budget = maxBytes - utf8.encode(extension).length;
final StringBuffer kept = StringBuffer();
int used = 0;
for (final int rune in base.runes) {
final int size = utf8.encode(String.fromCharCode(rune)).length;
if (used + size > budget) break;
kept.writeCharCode(rune);
used += size;
}
final String trimmed = kept.toString().trimRight();
return trimmed.isEmpty
? '$kFallbackFileName$extension'
: '$trimmed$extension';
}
+95
View File
@@ -0,0 +1,95 @@
/// 받은 파일을 다른 앱으로 열 때의 형식(MIME)과 앱에서 열지 않는 형식 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈).
///
/// 형식은 보낸 쪽이 정한 글이라 확장자보다 믿지 않는다 — 열 형식은 정리한 이름([safeFileName])의 확장자에서 먼저 정하고
/// (`package:mime` 의 표), 확장자를 모를 때만 서버가 알려 준 형식(첨부의 `type`)을 쓴다. 안드로이드는 이 형식으로 열 앱을 고른다.
///
/// 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 앱에서 열지 않는다 — 다른 앱(브라우저)이 그 파일의 스크립트를 돌리거나
/// 기기에 설치 · 등록하는 창을 띄우지 않게. 확장자 · 확장자에서 나온 형식 · 서버 형식 가운데 하나라도 걸리면 받지도 않는다.
/// 고르는 창(`Intent.createChooser`)으로 여는 길도 쓰지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다
library;
import 'package:arasystem_app/core/files/file_names.dart';
import 'package:mime/mime.dart';
/// 형식을 모를 때 — 안드로이드는 이 형식을 받는 앱이 드물다(열 앱이 없다고 알린다)
const String kGenericFileType = 'application/octet-stream';
/// 앱에서 열지 않는 확장자 (소문자 — 정리한 이름의 마지막 점 뒤)
const Set<String> kBlockedOpenExtensions = <String>{
// 웹 문서 — 브라우저가 열면 스크립트가 돈다
'html', 'htm', 'xhtml', 'xht', 'shtml', 'mht', 'mhtml', //
// SVG — 그림이지만 스크립트를 담는다
'svg', 'svgz', //
// 설치 파일
'apk', 'apks', 'xapk', //
// 인증서 · 키 묶음 — 열면 기기에 설치하는 창이 뜬다
'p12', 'pfx', 'cer', 'crt', 'der', 'pem', 'p7b', 'p7c',
};
/// 앱에서 열지 않는 형식 — 위 확장자에서 나오는 형식(`package:mime` 의 표 — 시험이 빠짐없이 맞춰 본다)과
/// 서버 · 보낸 쪽이 같은 갈래에 흔히 쓰는 형식
const Set<String> kBlockedOpenTypes = <String>{
// 웹 문서 · MHTML
'text/html', 'application/xhtml+xml', 'multipart/related', //
'application/x-mimearchive', //
// SVG
'image/svg+xml', //
// 설치 파일
'application/vnd.android.package-archive', //
// 인증서 · 키 묶음
'application/x-pkcs12', 'application/pkcs12', 'application/pkix-cert', //
'application/x-x509-ca-cert', 'application/x-x509-user-cert', //
'application/x-x509-server-cert', 'application/x-pem-file', //
'application/x-pkcs7-certificates', 'application/pkcs7-mime', //
'application/x-pkcs7-mime', 'application/x-pkcs7-certreqresp',
};
/// 형식 글의 모양 — `종류/이름` (짝: `MainActivity.kt` 의 `MIME_PATTERN` — 앱도 넘겨받은 형식을 이 모양으로 다시 본다)
final RegExp _typePattern = RegExp(
r'^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$',
);
/// 정리한 이름의 확장자 — 소문자, 없으면 빈 글
String fileExtensionOf(String fileName) {
final String name = safeFileName(fileName);
final int dot = name.lastIndexOf('.');
if (dot <= 0 || dot == name.length - 1) return '';
return name.substring(dot + 1).toLowerCase();
}
/// 서버가 알려 준 형식을 비교할 꼴로 — 소문자, `;` 뒤의 매개변수(`charset=` 등)를 뗀다. 모양이 틀리면 null
String? normalizedFileType(String? type) {
if (type == null) return null;
final int semicolon = type.indexOf(';');
final String value = (semicolon == -1 ? type : type.substring(0, semicolon))
.trim()
.toLowerCase();
return _typePattern.hasMatch(value) ? value : null;
}
/// 확장자에서 나오는 형식 — 모르거나 아무 형식(octet-stream)이면 null
String? fileTypeOfExtension(String extension) {
if (extension.isEmpty) return null;
final String? type = lookupMimeType('file.$extension');
return type == null || type == kGenericFileType ? null : type;
}
/// 다른 앱으로 열 때 넘길 형식 — 확장자에서 먼저, 모르면 서버 형식(모양이 맞고 아무 형식이 아닐 때), 그것도 아니면 [kGenericFileType]
String fileOpenTypeOf(String fileName, String? serverType) {
final String? byExtension = fileTypeOfExtension(fileExtensionOf(fileName));
if (byExtension != null) return byExtension;
final String? given = normalizedFileType(serverType);
return given ?? kGenericFileType;
}
/// 앱에서 열지 않는 파일인가 — 확장자 · 확장자에서 나온 형식 · 서버 형식 가운데 하나라도 [kBlockedOpenExtensions] · [kBlockedOpenTypes] 에 걸리면
bool isBlockedFileOpen(String fileName, String? serverType) {
final String extension = fileExtensionOf(fileName);
if (kBlockedOpenExtensions.contains(extension)) return true;
final String? byExtension = fileTypeOfExtension(extension);
if (byExtension != null && kBlockedOpenTypes.contains(byExtension)) {
return true;
}
final String? given = normalizedFileType(serverType);
return given != null && kBlockedOpenTypes.contains(given);
}
+22
View File
@@ -120,4 +120,26 @@ abstract final class AppSize {
/// [메일 쓰기] 단추 높이 (Material 넓은 단추 그대로) — 목록 아래 여백이 이만큼 + 40 이다
static const double fabHeight = 56;
// ── 앱 보드에서 정한 크기 (`App1Reader` — 2026-11-05 확정) ──
/// 읽기 화면의 위 막대 높이 — 뒤로 · 삭제 · 더 보기
static const double readerBarHeight = 56;
/// 읽기 화면의 아래 행동 막대 높이 — 답장 · 전체 답장 · 전달
static const double readerActionBarHeight = 64;
/// 첨부 줄의 최소 높이
static const double attachmentRowMinHeight = 64;
/// 첨부 줄의 파일 아이콘 칸
static const double fileIconBox = 40;
/// 첨부를 받는 동안의 진행 막대 두께
static const double progressBarHeight = 4;
/// 머리 펼침의 칸 이름 열 폭 ("보낸 사람" · "받는 사람" · "숨은참조")
static const double detailsLabelWidth = 64;
/// 더 보기 메뉴의 폭
static const double readerMenuWidth = 224;
}
+33
View File
@@ -0,0 +1,33 @@
/// 파일 크기 표기 — 웹 `shared/utils/format.ts` 의 `formatFileSize` 와 같은 글(순수 함수).
///
/// "248 KB" · "1.2 MB" · "38 MB" — 1KB 미만은 바이트 그대로, 그 위는 KB · MB · GB · TB. KB 는 정수, MB 이상은 10 미만일 때 소수 한 자리.
library;
const int _bytesPerUnit = 1024;
const List<String> _units = <String>['KB', 'MB', 'GB', 'TB'];
const int _decimalThreshold = 10;
/// 크기 표기 — 음수면 빈 글
String formatFileSize(int bytes) {
if (bytes < 0) return '';
if (bytes < _bytesPerUnit) return '$bytes B';
double value = bytes / _bytesPerUnit;
int unit = 0;
// 반올림 결과가 1024 가 되면("1024 KB") 다음 단위로 올린다
while (value.round() >= _bytesPerUnit && unit < _units.length - 1) {
value /= _bytesPerUnit;
unit++;
}
final String shown = unit > 0 && value < _decimalThreshold
? _oneDecimal(value)
: value.round().toString();
return '$shown ${_units[unit]}';
}
/// 소수 한 자리 — 끝의 ".0" 은 뺀다(웹 `Math.round(value * 10) / 10` 이 내는 글과 같다)
String _oneDecimal(double value) {
final double rounded = (value * 10).round() / 10;
return rounded == rounded.roundToDouble()
? rounded.round().toString()
: rounded.toStringAsFixed(1);
}
+12
View File
@@ -37,6 +37,18 @@ String koreanMonthDay(DateTime local) => '${local.month}월 ${local.day}일';
String koreanNumericDate(DateTime local) =>
'${local.year}. ${local.month}. ${local.day}.';
/// 요일 한 글자 — 월 · 화 · … · 일 (웹 `{ weekday: 'short' }`)
String koreanWeekday(DateTime local) =>
const <String>['월', '화', '수', '목', '금', '토', '일'][local.weekday - 1];
/// 달 · 날 · 요일 · 시각 — "11월 7일 (토) 오전 9:00" (웹 `formatScheduleTime` — 예약 시각. 7일 안이라 연도가 없다)
String koreanScheduleTime(DateTime local) =>
'${koreanMonthDay(local)} (${koreanWeekday(local)}) ${koreanTimeOfDay(local)}';
/// 연도까지 — "2026년 11월 4일 (수) 오후 4:20" (앱 보드 `App1Reader` 의 머리 펼침 "날짜")
String koreanFullDateTime(DateTime local) =>
'${local.year}년 ${koreanScheduleTime(local)}';
/// 같은 달력 날인가 (둘 다 같은 시간대로 바꾼 값)
bool isSameCalendarDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
@@ -0,0 +1,20 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
/// 본문의 http · https 링크를 바깥 앱(브라우저 · 그 주소를 받는 앱)으로 여는 함수 — 시험은 덮어쓴다
typedef MailLinkLauncher = Future<bool> Function(Uri uri);
final Provider<MailLinkLauncher> mailLinkLauncherProvider =
Provider<MailLinkLauncher>((Ref ref) => openMailLinkExternally);
/// 바깥 앱으로 연다 — 열었으면 true.
///
/// `canLaunchUrl` 은 쓰지 않는다(debug 판 안드로이드가 묻는 주소를 로그에 남긴다 — flutter 스킬). 못 열면 플러그인이 던지는
/// 예외의 문장에 주소째 들어 있다 — 잡아서 버린다(메일 속 주소에는 추적 번호 · 개인 정보가 들어 있을 수 있다)
Future<bool> openMailLinkExternally(Uri uri) async {
try {
return await launchUrl(uri, mode: LaunchMode.externalApplication);
} on Object {
return false;
}
}
@@ -0,0 +1,267 @@
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:flutter/foundation.dart';
/// 메일 한 통의 첨부 — 계약 2절 `MessageAttachment`
@immutable
class MessageAttachment {
const MessageAttachment({
required this.id,
required this.name,
required this.type,
required this.size,
this.isInline = false,
});
final String id;
/// 보낸 쪽이 붙인 이름 — 저장할 때는 다시 정리한다(`safeFileName`)
final String name;
/// 서버가 알려 준 형식(`application/pdf` 등) — 다른 앱으로 열 때 쓴다
final String type;
/// 바이트
final int size;
/// 본문 안에 그림으로 들어갔다(cid → data:) — 첨부 목록에서 뺀다
final bool isInline;
/// 모양이 틀리면 null — `isInline` 이 없으면(옛 서버) 거짓으로 본다
static MessageAttachment? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? id = json['id'];
final Object? name = json['name'];
final Object? type = json['type'];
final Object? size = json['size'];
final Object? isInline = json['isInline'];
if (id is! String ||
id.isEmpty ||
name is! String ||
type is! String ||
size is! num ||
(isInline != null && isInline is! bool)) {
return null;
}
return MessageAttachment(
id: id,
name: name,
type: type,
size: size.toInt(),
isInline: isInline == true,
);
}
@override
bool operator ==(Object other) =>
other is MessageAttachment &&
other.id == id &&
other.name == name &&
other.type == type &&
other.size == size &&
other.isInline == isInline;
@override
int get hashCode => Object.hash(id, name, type, size, isInline);
}
/// 우리 서버가 넣은 대용량 첨부 하나 — 계약 4절 `MessageLargeFile` (첫 판은 받지 않고 이름 · 크기만 보인다)
@immutable
class MessageLargeFile {
const MessageLargeFile({
required this.fileId,
required this.name,
required this.size,
});
final String fileId;
/// 서버 기록의 이름
final String name;
/// 바이트
final int size;
static MessageLargeFile? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? fileId = json['fileId'];
final Object? name = json['name'];
final Object? size = json['size'];
if (fileId is! String || name is! String || size is! num) return null;
return MessageLargeFile(fileId: fileId, name: name, size: size.toInt());
}
@override
bool operator ==(Object other) =>
other is MessageLargeFile &&
other.fileId == fileId &&
other.name == name &&
other.size == size;
@override
int get hashCode => Object.hash(fileId, name, size);
}
/// 메일 한 통 — 계약 2절 `MessageDetail` (`GET /api/messages/{id}`)
///
/// 목록 칸([summary])과 읽기 칸을 함께 둔다. 9단계 뒤에 더해진 칸(`imagesShown` · `isHtmlBodyOmitted` · `isBodyTruncated` ·
/// `largeFiles` · 첨부의 `isInline`)은 없으면 거짓 · 빈 목록으로 읽는다(웹과 같다). 대용량 첨부는 하나가 틀려도 그것만 뺀다.
@immutable
class MessageDetail {
const MessageDetail({
required this.summary,
this.cc = const <MailAddress>[],
this.bcc = const <MailAddress>[],
this.sentAt,
this.htmlBody,
this.textBody,
this.hasBlockedImages = false,
this.imagesShown = false,
this.isHtmlBodyOmitted = false,
this.isBodyTruncated = false,
this.attachments = const <MessageAttachment>[],
this.largeFiles = const <MessageLargeFile>[],
});
/// 목록과 같은 칸 — 읽음 · 중요 · 메일함 · 붙잡힌 발송
final MessageSummary summary;
final List<MailAddress> cc;
/// 숨은참조 — 내가 보낸 메일에만 온다
final List<MailAddress> bcc;
final DateTime? sentAt;
/// 서버가 정화한 HTML — 앱은 한 번 더 정화한다(`mail_html.dart`)
final String? htmlBody;
final String? textBody;
/// 외부 이미지를 지웠다 — "외부 이미지 차단" 줄
final bool hasBlockedImages;
/// 외부 이미지를 중계 주소로 바꿔 넣었다(`images=show` 또는 설정) — 앱이 그 그림을 받아 넣는다
final bool imagesShown;
/// 서식 본문이 한도를 넘어 비웠다 — 글자 본문으로 보인다
final bool isHtmlBodyOmitted;
/// 보여 줄 본문이 읽기 한도에서 잘렸다
final bool isBodyTruncated;
final List<MessageAttachment> attachments;
final List<MessageLargeFile> largeFiles;
String get id => summary.id;
/// 첨부 목록에 보일 것 — 본문 안에 그림으로 들어간 것은 뺀다(웹 `listedAttachments`)
List<MessageAttachment> get listedAttachments => <MessageAttachment>[
for (final MessageAttachment attachment in attachments)
if (!attachment.isInline) attachment,
];
/// 목록 칸만 바꾼다 — 읽음 · 중요 · 메일함 · 붙잡힌 발송(본문은 그대로)
MessageDetail withSummary(MessageSummary next) => MessageDetail(
summary: next,
cc: cc,
bcc: bcc,
sentAt: sentAt,
htmlBody: htmlBody,
textBody: textBody,
hasBlockedImages: hasBlockedImages,
imagesShown: imagesShown,
isHtmlBodyOmitted: isHtmlBodyOmitted,
isBodyTruncated: isBodyTruncated,
attachments: attachments,
largeFiles: largeFiles,
);
/// 본문 칸만 [other] 의 것으로 — [이미지 보기]로 다시 받은 본문(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹 `showImages`)
MessageDetail withBodyOf(MessageDetail other) => MessageDetail(
summary: summary,
cc: cc,
bcc: bcc,
sentAt: sentAt,
htmlBody: other.htmlBody,
textBody: other.textBody,
hasBlockedImages: other.hasBlockedImages,
imagesShown: other.imagesShown,
isHtmlBodyOmitted: other.isHtmlBodyOmitted,
isBodyTruncated: other.isBodyTruncated,
attachments: other.attachments,
largeFiles: other.largeFiles,
);
/// 모양이 틀리면 null — 저장소가 `SYS_001` 로 던진다
static MessageDetail? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final MessageSummary? summary = MessageSummary.tryParse(json);
if (summary == null) return null;
final List<MailAddress>? cc = _addresses(json['cc']);
final List<MailAddress>? bcc = _addresses(json['bcc']);
final Object? sentAt = json['sentAt'];
final Object? htmlBody = json['htmlBody'];
final Object? textBody = json['textBody'];
final List<MessageAttachment>? attachments = _attachments(
json['attachments'],
);
final bool? hasBlockedImages = _flag(json['hasBlockedImages']);
final bool? imagesShown = _flag(json['imagesShown']);
final bool? omitted = _flag(json['isHtmlBodyOmitted']);
final bool? truncated = _flag(json['isBodyTruncated']);
final DateTime? sent = sentAt is String ? DateTime.tryParse(sentAt) : null;
if (cc == null ||
bcc == null ||
(sentAt != null && sent == null) ||
(htmlBody != null && htmlBody is! String) ||
(textBody != null && textBody is! String) ||
attachments == null ||
hasBlockedImages == null ||
imagesShown == null ||
omitted == null ||
truncated == null) {
return null;
}
return MessageDetail(
summary: summary,
cc: cc,
bcc: bcc,
sentAt: sent?.toUtc(),
htmlBody: htmlBody as String?,
textBody: textBody as String?,
hasBlockedImages: hasBlockedImages,
imagesShown: imagesShown,
isHtmlBodyOmitted: omitted,
isBodyTruncated: truncated,
attachments: attachments,
largeFiles: _largeFiles(json['largeFiles']),
);
}
/// 주소 칸 — 없으면 빈 목록, 모양이 틀리면 null
static List<MailAddress>? _addresses(Object? value) =>
value == null ? const <MailAddress>[] : MailAddress.tryParseList(value);
/// 참 · 거짓 칸 — 없으면 거짓, 모양이 틀리면 null
static bool? _flag(Object? value) {
if (value == null) return false;
return value is bool ? value : null;
}
static List<MessageAttachment>? _attachments(Object? value) {
if (value == null) return const <MessageAttachment>[];
if (value is! List<Object?>) return null;
final List<MessageAttachment> items = <MessageAttachment>[];
for (final Object? item in value) {
final MessageAttachment? attachment = MessageAttachment.tryParse(item);
if (attachment == null) return null;
items.add(attachment);
}
return List<MessageAttachment>.unmodifiable(items);
}
/// 대용량 첨부 — 11단계 칸이라 모양이 틀린 것은 빼고 읽는다(메일 전체를 열지 못하게 하지 않는다)
static List<MessageLargeFile> _largeFiles(Object? value) {
if (value is! List<Object?>) return const <MessageLargeFile>[];
return List<MessageLargeFile>.unmodifiable(<MessageLargeFile>[
for (final Object? item in value) ?MessageLargeFile.tryParse(item),
]);
}
}
@@ -1,9 +1,15 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/api/server_time.dart';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:dio/dio.dart';
@@ -20,8 +26,24 @@ abstract final class MailApiPaths {
/// 새 메일 확인 — 불투명한 상태 글
static const String mailState = '/mail-state';
/// 첨부 받기 — 표준 래퍼 없는 파일
static String attachment(String messageId, String attachmentId) =>
'${message(messageId)}/attachments/${Uri.encodeComponent(attachmentId)}';
/// 붙잡아 둔 발송 취소(보내기 취소 · 예약 취소) — id 는 보낸 사본의 메일 id (9단계)
static String scheduledMessage(String id) =>
'/scheduled-messages/${Uri.encodeComponent(id)}';
/// 외부 이미지 중계 — 서버가 본문에 넣은 주소(`/api/mail-images/<표>`)의 표 (9단계)
static String mailImage(String token) =>
'/mail-images/${Uri.encodeComponent(token)}';
}
/// 메일 한 통을 외부 이미지가 보이는 본문으로 받는 쿼리 — `?images=show` (계약 2절 "외부 이미지 보기")
const String kMailImagesQuery = 'images';
const String kMailImagesShow = 'show';
/// 목록 한 쪽의 기본 수 — 계약 기본값(20)과 같다
const int kMailPageSize = 20;
@@ -76,14 +98,35 @@ class MailRepository {
return page.withServerTime(serverTimeOf(response));
}
/// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다)
/// 메일 한 통 — 조회만으로는 읽음 처리하지 않는다(계약 2절). [showImages] 면 외부 이미지를 보이는 본문(`images=show`).
/// 알림은 [silent] 로 끈다 — 처음 여는 메일은 화면이 받지 못함을 직접 그린다. 응답의 서버 시각(`Date`)도 함께 준다
Future<({MessageDetail detail, DateTime? serverTime})> getMessage(
String id, {
bool showImages = false,
bool silent = true,
}) async {
final Response<Object?> response = await _dio.get<Object?>(
MailApiPaths.message(id),
queryParameters: showImages
? <String, Object?>{kMailImagesQuery: kMailImagesShow}
: null,
options: silent ? ApiOptions.silent() : null,
);
final MessageDetail? detail = MessageDetail.tryParse(response.data);
if (detail == null) throw _malformed(response);
return (detail: detail, serverTime: serverTimeOf(response));
}
/// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다). [silent] 면 실패를 알리지 않는다(연 메일의 읽음 처리)
Future<MessageSummary> updateMessage(
String id,
MessageChanges changes,
) async {
MessageChanges changes, {
bool silent = false,
}) async {
final Response<Object?> response = await _dio.patch<Object?>(
MailApiPaths.message(id),
data: changes.toJson(),
options: silent ? ApiOptions.silent() : null,
);
final MessageSummary? summary = MessageSummary.tryParse(response.data);
if (summary == null) throw _malformed(response);
@@ -127,6 +170,130 @@ class MailRepository {
return state;
}
/// 붙잡아 둔 발송을 취소한다(보내기 취소 · 예약 취소) — 메일은 보내지 않고 임시보관함으로 간다. 이어 쓸 초안 id(같은 메일 id)를 준다.
/// 이미 보냈으면 409 `MAIL_014`, 없으면 404 `RES_001` — 알림은 인터셉터가 띄운다 (계약 2절 "보내기 취소 · 예약 발송")
Future<String> cancelPendingSend(String id) async {
final Response<Object?> response = await _dio.delete<Object?>(
MailApiPaths.scheduledMessage(id),
);
final Object? data = response.data;
final Object? draftId = data is Map<String, Object?>
? data['draftId']
: null;
if (draftId is! String || draftId.isEmpty) throw _malformed(response);
return draftId;
}
/// 외부 이미지 하나 — 서버가 바깥에서 대신 받아 준 그림(바이트). 실패는 알리지 않는다(그 자리를 비운다).
/// [maxBytes] 를 넘게 오면 받기를 끊는다(취소 — `DioExceptionType.cancel`). [cancelToken] 을 그만두면(화면을 떠남 · 본문이 바뀜) 이 받기도 끊는다 —
/// 한도로 끊는 토큰은 그림마다 따로 둔다(한 그림의 한도가 다른 그림을 끊지 않게)
Future<Uint8List> fetchMailImage(
String token, {
required int maxBytes,
CancelToken? cancelToken,
}) async {
final CancelToken cancel = CancelToken();
if (cancelToken != null) {
if (cancelToken.isCancelled) cancel.cancel();
unawaited(
cancelToken.whenCancel.then((DioException _) {
if (!cancel.isCancelled) cancel.cancel();
}),
);
}
final Response<List<int>> response = await _dio.get<List<int>>(
MailApiPaths.mailImage(token),
options: Options(
responseType: ResponseType.bytes,
extra: <String, Object?>{kSilentExtraKey: true},
),
cancelToken: cancel,
onReceiveProgress: (int received, int total) {
if (received > maxBytes || total > maxBytes) cancel.cancel();
},
);
final List<int>? data = response.data;
if (data == null) {
throw apiFailure(response.requestOptions, kFallbackCode);
}
return data is Uint8List ? data : Uint8List.fromList(data);
}
/// 첨부 하나를 [target] 에 받는다 — 조각마다 [onProgress](받은 바이트 · 전체 바이트 — 모르면 null).
///
/// 실패는 늘 `DioException` 이다 — 서버가 거절했으면 인터셉터가 코드로 알렸고(실패 본문도 코드로 읽는다),
/// 받는 도중 끊기면 `SYS_001`(이때는 응답이 이미 지나가 인터셉터가 알리지 않는다 — [DownloadInterrupted]).
/// [cancelToken] 으로 그만두면 `DioExceptionType.cancel` 이다. 쓰다 만 [target] 은 부르는 쪽이 지운다
Future<void> downloadAttachment({
required String messageId,
required String attachmentId,
required DownloadTarget target,
void Function(int received, int? total)? onProgress,
CancelToken? cancelToken,
}) async {
final Response<ResponseBody> response = await _dio.get<ResponseBody>(
MailApiPaths.attachment(messageId, attachmentId),
options: Options(responseType: ResponseType.stream),
cancelToken: cancelToken,
);
final ResponseBody? body = response.data;
if (body == null) {
throw apiFailure(response.requestOptions, kFallbackCode);
}
final int? length = int.tryParse(
response.headers.value(Headers.contentLengthHeader) ?? '',
);
final int? total = length != null && length > 0 ? length : null;
final RequestOptions request = response.requestOptions;
final Completer<void> done = Completer<void>();
int received = 0;
final StreamSubscription<Uint8List> subscription = body.stream.listen(
(Uint8List chunk) {
target.add(chunk);
received += chunk.length;
onProgress?.call(received, total);
},
onError: (Object error, StackTrace stackTrace) {
if (done.isCompleted) return;
// 받는 도중의 실패(끊김 · 조각 사이 받기 한도 `receiveTimeout`)는 인터셉터를 지나지 않는다 — 그만둔 것만 그대로 둔다
final bool cancelled =
error is DioException && error.type == DioExceptionType.cancel;
done.completeError(
cancelled ? error : DownloadInterrupted(request),
stackTrace,
);
},
onDone: () {
if (!done.isCompleted) done.complete();
},
cancelOnError: true,
);
// 그만두면 다음 조각을 기다리지 않고 바로 끊는다
unawaited(
cancelToken?.whenCancel.then((DioException _) async {
if (done.isCompleted) return;
done.completeError(
DioException.requestCancelled(
requestOptions: request,
reason: cancelToken.cancelError?.error,
),
);
await subscription.cancel();
}),
);
await done.future;
}
DioException _malformed(Response<Object?> response) =>
apiFailure(response.requestOptions, kFallbackCode);
}
/// 첨부를 받는 도중 끊겼다 — 응답은 이미 인터셉터를 지나서 아무도 알리지 않았다(부르는 쪽이 `SYS_001` 로 알린다)
class DownloadInterrupted extends DioException {
DownloadInterrupted(RequestOptions requestOptions)
: super(
requestOptions: requestOptions,
type: DioExceptionType.connectionError,
error: ApiException.fromCode(kFallbackCode),
);
}
@@ -0,0 +1,200 @@
/// 쓰기 화면에 넘길 값 — 읽기 화면의 [답장] · [전체 답장] · [전달] · 본문의 mailto 링크 (순수 함수).
///
/// 쓰기 화면(`App1Compose`)은 다음 차례다. 지금은 가는 길(주소 · 쿼리)과 넘길 값만 정해 둔다 —
/// 주소는 웹과 같은 쿼리(`/mail/compose?replyTo=<id>` · `&all=1` · `?forward=<id>` · `?draft=<id>`)이고,
/// mailto 의 받는 사람 · 제목 · 본문은 주소에 싣지 않고 라우트의 `extra`([ComposePrefill])로 넘긴다
/// (웹 `compose-prefill.ts` 처럼 — 주소가 기록 · 로그에 남지 않게).
library;
import 'package:flutter/foundation.dart';
/// 쓰기 화면 주소의 쿼리 이름 — 웹 `/mail/compose` 와 같다
abstract final class ComposeQuery {
static const String draft = 'draft';
static const String replyTo = 'replyTo';
/// [replyTo] 와 함께 `1` 이면 전체 답장
static const String replyAll = 'all';
static const String replyAllValue = '1';
static const String forward = 'forward';
}
/// 쓰기 화면을 여는 까닭
sealed class ComposeIntent {
const ComposeIntent();
}
/// 새 메일 (mailto 로 채운 것 포함 — 채울 값은 [ComposePrefill])
final class NewComposeIntent extends ComposeIntent {
const NewComposeIntent();
}
/// 임시 저장본 이어 쓰기
final class DraftComposeIntent extends ComposeIntent {
const DraftComposeIntent(this.draftId);
final String draftId;
}
/// 답장 · 전체 답장
final class ReplyComposeIntent extends ComposeIntent {
const ReplyComposeIntent(this.messageId, {required this.all});
final String messageId;
final bool all;
}
/// 전달
final class ForwardComposeIntent extends ComposeIntent {
const ForwardComposeIntent(this.messageId);
final String messageId;
}
/// 쓰기 화면 주소의 쿼리 → 여는 까닭 (모르는 값은 새 메일 — 웹과 같은 차례: 이어 쓰기 → 답장 → 전달)
ComposeIntent composeIntentOf(Map<String, String> query) {
final String? draft = query[ComposeQuery.draft];
if (draft != null && draft.isNotEmpty) return DraftComposeIntent(draft);
final String? replyTo = query[ComposeQuery.replyTo];
if (replyTo != null && replyTo.isNotEmpty) {
return ReplyComposeIntent(
replyTo,
all: query[ComposeQuery.replyAll] == ComposeQuery.replyAllValue,
);
}
final String? forward = query[ComposeQuery.forward];
if (forward != null && forward.isNotEmpty) {
return ForwardComposeIntent(forward);
}
return const NewComposeIntent();
}
/// mailto 링크로 채울 값 — 쓰기 화면이 받는 사람 · 제목 · 본문 칸을 채운다
@immutable
class ComposePrefill {
const ComposePrefill({
this.to = const <String>[],
this.cc = const <String>[],
this.bcc = const <String>[],
this.subject = '',
this.body = '',
});
final List<String> to;
final List<String> cc;
final List<String> bcc;
final String subject;
final String body;
@override
bool operator ==(Object other) =>
other is ComposePrefill &&
listEquals(other.to, to) &&
listEquals(other.cc, cc) &&
listEquals(other.bcc, bcc) &&
other.subject == subject &&
other.body == body;
@override
int get hashCode => Object.hash(
Object.hashAll(to),
Object.hashAll(cc),
Object.hashAll(bcc),
subject,
body,
);
}
/// 받는 사람 합계 한도 — 발송 한도와 같다(계약 2절 — 100명)
const int kComposeMaxRecipients = 100;
/// 제목 한도 — 발송 한도와 같다(255자)
const int kComposeMaxSubject = 255;
/// mailto 로 채우는 본문 한도 — 링크에 실린 긴 글로 화면이 무거워지지 않게(발송 한도 100,000자보다 짧게 둔다)
const int kComposeMaxPrefillBody = 10000;
/// 주소 한 칸의 최대 길이 (RFC 5321)
const int _maxAddressLength = 254;
/// mailto 링크 → 채울 값 — mailto 가 아니면 null.
///
/// `mailto:a@x,b@x?cc=…&bcc=…&subject=…&body=…`(RFC 6068). 받는 사람은 `@` 가 있고 공백 · 제어 문자가 없는 것만,
/// 겹치면 하나, 합계 [kComposeMaxRecipients] 까지. 제목 · 본문은 한도에서 자른다. 받는 사람이 없어도 된다(빈 쓰기 화면).
ComposePrefill? composePrefillOfMailto(Uri uri) {
if (uri.scheme.toLowerCase() != 'mailto') return null;
final Map<String, String> query = <String, String>{};
final String rawQuery = uri.hasQuery ? uri.query : '';
if (rawQuery.isNotEmpty) {
for (final String part in rawQuery.split('&')) {
final int equals = part.indexOf('=');
final String key = _decode(
equals == -1 ? part : part.substring(0, equals),
).toLowerCase();
final String value = equals == -1
? ''
: _decode(part.substring(equals + 1));
// 같은 이름이 여럿이면 앞의 것 — 받는 사람 칸은 이어 붙인다
if (key == 'to' || key == 'cc' || key == 'bcc') {
query[key] = query[key] == null ? value : '${query[key]},$value';
} else {
query.putIfAbsent(key, () => value);
}
}
}
final Set<String> seen = <String>{};
List<String> addresses(String raw) {
final List<String> out = <String>[];
for (final String piece in raw.split(',')) {
final String address = piece.trim();
if (!_isPlausibleAddress(address)) continue;
if (seen.length >= kComposeMaxRecipients) break;
if (!seen.add(address.toLowerCase())) continue;
out.add(address);
}
return List<String>.unmodifiable(out);
}
return ComposePrefill(
to: addresses(<String>[_decode(uri.path), query['to'] ?? ''].join(',')),
cc: addresses(query['cc'] ?? ''),
bcc: addresses(query['bcc'] ?? ''),
subject: _limit(_singleLine(query['subject'] ?? ''), kComposeMaxSubject),
body: _limit(query['body'] ?? '', kComposeMaxPrefillBody),
);
}
/// 퍼센트 인코딩을 푼다 — 깨진 인코딩은 그대로 둔다
String _decode(String value) {
try {
return Uri.decodeComponent(value);
} on ArgumentError {
return value;
} on FormatException {
return value;
}
}
bool _isPlausibleAddress(String address) {
if (address.isEmpty || address.length > _maxAddressLength) return false;
final int at = address.indexOf('@');
if (at <= 0 || at != address.lastIndexOf('@') || at == address.length - 1) {
return false;
}
return !address.runes.any(
(int rune) => rune <= 0x20 || (rune >= 0x7F && rune <= 0x9F),
);
}
/// 제목은 한 줄 — 줄바꿈 · 제어 문자는 띄어쓰기로
String _singleLine(String value) =>
value.replaceAll(RegExp(r'[\u0000-\u001F\u007F]+'), ' ').trim();
/// [max] 자(UTF-16)에서 자른다 — 이모지 같은 짝 글자(서로게이트)를 가르지 않는다
String _limit(String value, int max) {
if (value.length <= max) return value;
final int end = _isHighSurrogate(value.codeUnitAt(max - 1)) ? max - 1 : max;
return value.substring(0, end);
}
bool _isHighSurrogate(int unit) => unit >= 0xD800 && unit <= 0xDBFF;
@@ -0,0 +1,32 @@
/// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 "읽기 한도" · 웹 `shared/utils/mail-body-notices.ts` 와 같다).
///
/// 차례는 생략(서식 없이 · 표시할 수 없음) → 잘림이다. 보여 줄 본문이 없으면("표시할 수 없음") 잘림 안내는 맞지 않아 뺀다.
/// 글자 본문은 공백뿐이면 없는 것으로 본다.
library;
/// 본문 안내의 종류
enum MailBodyNotice {
/// 서식 본문을 한도 때문에 비웠다 — 글자 본문을 보인다
htmlOmitted,
/// 서식 본문을 비웠고 글자 본문도 없다
unavailable,
/// 보여 주는 본문이 읽기 한도에서 잘렸다
truncated,
}
/// 본문 위에 둘 안내 — 없으면 빈 목록
List<MailBodyNotice> mailBodyNoticesOf({
required String? textBody,
required bool isHtmlBodyOmitted,
required bool isBodyTruncated,
}) {
final bool hasText = (textBody ?? '').trim().isNotEmpty;
return <MailBodyNotice>[
if (isHtmlBodyOmitted)
hasText ? MailBodyNotice.htmlOmitted : MailBodyNotice.unavailable,
if (isBodyTruncated && !(isHtmlBodyOmitted && !hasText))
MailBodyNotice.truncated,
];
}
@@ -0,0 +1,503 @@
/// 받은 메일 본문 → WebView 에 넣을 문서 — 보안 핵심 (순수 함수 · 웹 `shared/utils/mail-html.ts` 의 결을 따른다).
///
/// 방어는 세 겹이다. 어느 한 겹이 뚫려도 나머지가 막도록 셋 다 유지한다.
/// 1) 서버가 정화한 htmlBody (계약 2절 "htmlBody 정화 규칙")
/// 2) 여기서 한 번 더 정화 — HTML5 파서(`package:html`)로 읽어 **허용 목록의 태그 · 속성만** 다시 쓴다.
/// 서버 정화에 빈틈이 있어도 스크립트 · 폼 · 외부 자원 · 이벤트 처리기를 걷어 낸다. 결과 HTML 은 파서의 직렬화가 아니라
/// 여기서 직접 쓴다(속성 값의 `<` `>` `"` `&` 를 늘 이스케이프 — 아래 그림 주소 바꿔 끼우기가 그 모양에 기댄다).
/// 3) WebView — 스크립트를 끄고(`JavaScriptMode.disabled`), 이 문서 안의 CSP 로 모든 바깥 요청 · 아래 틀을 막고(그림은 `data:` 만),
/// 주 틀의 탐색은 모두 막는다(링크는 앱이 바깥 브라우저 · 쓰기 화면으로 — `mail_links.dart`). 아래 틀(iframe)의 탐색은 안드로이드가
/// 판정에 넘기지 않으므로 정화(iframe 을 내용째 버림)와 CSP(frame-src)만 막는다.
///
/// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 —
/// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다.
library;
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
/// WebView 문서 안의 CSP — 스크립트 · 글꼴 · 네트워크 요청을 모두 막고 `data:` 그림과 인라인 스타일만 연다.
/// 웹(`MAIL_FRAME_CSP`)과 같고, `<base>` · 폼 보내기도 막는다(둘은 `default-src` 를 따르지 않는다).
/// 외부 이미지를 보일 때도 같다 — 그림은 앱이 받아 `data:` 로 넣는다(웹처럼 중계 경로를 열지 않는다)
const String kMailDocumentCsp =
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'";
/// 서버의 중계 주소 — `/api/mail-images/<표>`. 표는 서버가 암호화한 불투명 글(base64url)이라 한 칸뿐이다
/// (짝: backend `modules/mail/images/mail-image-token.ts` 의 `TOKEN_PATTERN` · 웹 `PROXIED_IMAGE_PATTERN`)
final RegExp kProxiedImagePattern = RegExp(
r'^/api/mail-images/([A-Za-z0-9_-]+)$',
);
/// 한 통에서 받아 넣을 중계 그림의 최대 수 — 서버가 한 통에 바꿔 넣는 수와 같다(계약 2절 "외부 이미지 보기" — 100개)
const int kMaxProxiedImages = 100;
/// 남길 링크 — 계약과 같다(http · https · mailto). 상대 경로 · javascript: 등은 지운다
final RegExp _safeLink = RegExp(r'^(?:https?|mailto):', caseSensitive: false);
/// 남길 `data:` 그림 — 서버가 본문에 넣어 주는 그림 형식(PNG · JPEG · GIF · WebP)만. SVG 는 받지 않는다
final RegExp _dataImage = RegExp(
r'^data:image/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=\s]*$',
caseSensitive: false,
);
/// 남기는 태그 — 메일 본문에 흔한 글 · 표 · 목록 · 인용 태그(DOMPurify 의 HTML 목록에서 폼 · 미디어 · 틀을 뺐다)
const Set<String> _allowedTags = <String>{
'a', 'abbr', 'acronym', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', //
'big', 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', //
'colgroup', 'data', 'dd', 'del', 'details', 'dfn', 'dir', 'div', 'dl', //
'dt', 'em', 'figcaption', 'figure', 'font', 'footer', 'h1', 'h2', 'h3', //
'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'img', 'ins', 'kbd', //
'li', 'main', 'mark', 'menu', 'nav', 'nobr', 'ol', 'p', 'pre', 'q', 'rp', //
'rt', 'ruby', 's', 'samp', 'section', 'small', 'span', 'strike', 'strong', //
'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', //
'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr',
};
/// 내용째 버리는 태그 — 스크립트 · 스타일 · 틀 · 미디어 · SVG · 수식 · 폼의 숨은 값(웹 `FORBID_CONTENTS` 와 서버 제거 목록)
const Set<String> _droppedWithContent = <String>{
'script', 'style', 'template', 'iframe', 'frame', 'frameset', 'object', //
'embed', 'applet', 'param', 'noscript', 'noembed', 'noframes', 'svg', //
'math', 'datalist', 'title', 'head', 'xmp', 'plaintext', 'audio', //
'video', 'source', 'track', 'canvas', 'map', 'area', 'link', 'meta', //
'base', 'input', 'select', 'dialog', 'slot',
};
/// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다
const Set<String> _voidTags = <String>{'br', 'col', 'hr', 'img', 'wbr'};
/// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다
const Set<String> _globalAttributes = <String>{
'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', //
'height', 'bgcolor', 'color', 'border', 'cellpadding', 'cellspacing', //
'colspan', 'rowspan', 'span', 'face', 'size', 'nowrap', 'frame', 'rules', //
'summary', 'abbr', 'headers', 'scope', 'hspace', 'vspace', 'clear', //
'noshade', 'datetime',
};
/// 태그마다 더 남기는 속성
const Map<String, Set<String>> _tagAttributes = <String, Set<String>>{
'a': <String>{'href'},
'img': <String>{'src', 'alt'},
'ol': <String>{'start', 'type', 'reversed'},
'ul': <String>{'type'},
'li': <String>{'type', 'value'},
'details': <String>{'open'},
};
/// 속성 값의 최대 길이 — 넘으면 그 속성을 뺀다(`style` 은 표 꾸밈이 길 수 있어 넉넉히 · 주소는 따로)
const int _maxAttributeLength = 1000;
const int _maxStyleLength = 4000;
/// 링크 주소의 최대 길이 — 중계 주소 한도(2,048자)와 같다
const int _maxHrefLength = 2048;
/// 앱이 정화하는 서식 본문의 최대 길이(글자) — 서버의 읽기 한도와 같다(계약 2절 "읽기 한도" — 2,000,000)
const int kMaxSanitizedHtmlLength = 2000000;
/// 앱이 정화하는 서식 본문의 최대 중첩 비용 — 태그마다 그때의 깊이를 더한 값([htmlNestingCost]).
///
/// HTML5 파서(`package:html`)는 태그마다 열린 요소를 훑어(범위 검사) 깊이에 비례해 일하므로 깊은 본문은 제곱으로 느려진다
/// (2026-11-06 이 PC 의 시험 판: 깊이 200 · 2MB 2.2초, 깊이 600 · 2MB 6.4초, div 5,000단 55KB 3.8초 — 서버는 2만 단까지 넘긴다).
/// 이 값이면 정화가 2MB 표 한 통(1.5초)보다 2초 남짓 더 걸리는 데서 멈춘다. 넘으면 서식 없이 글자 본문을 보인다
/// (서버의 `isHtmlBodyOmitted` 와 같은 안내 — 50단 안팎의 보통 답장 스레드는 한참 아래다)
const int kMaxHtmlNestingCost = 8000000;
/// 정화한 본문 조각 — `<body>` 안에 넣는다
class SanitizedMailBody {
const SanitizedMailBody({
required this.fragment,
required this.proxiedTokens,
this.htmlOmitted = false,
});
/// 정화한 HTML 조각 — 중계 그림은 `<img src="/api/mail-images/<표>"` 꼴로 남아 있다([resolveProxiedImages] 가 바꾼다)
final String fragment;
/// 받아 넣을 중계 그림의 표 — 문서 차례 · 겹치지 않게 · [kMaxProxiedImages] 까지(외부 이미지를 보일 때만)
final List<String> proxiedTokens;
/// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다
final bool htmlOmitted;
}
/// 보여 줄 서식 본문 — null 이거나 공백뿐이면 없다(null). 그때는 textBody 를 보인다(웹 `displayedHtmlBody` · 서버와 같은 규칙)
String? displayedHtmlBody(String? htmlBody) =>
htmlBody != null && htmlBody.trim().isNotEmpty ? htmlBody : null;
/// 본문 조각을 만든다 — HTML 이 있으면 정화하고, 없으면(공백뿐 포함) 텍스트를 이스케이프한다.
/// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다).
/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다
SanitizedMailBody mailBodyFragment({
required String? htmlBody,
required String? textBody,
required bool imagesShown,
}) {
final String? html = displayedHtmlBody(htmlBody);
if (html == null) return _textFragment(textBody);
final String trimmed = html.trim();
if (trimmed.length > kMaxSanitizedHtmlLength ||
htmlNestingCost(trimmed, limit: kMaxHtmlNestingCost) >
kMaxHtmlNestingCost) {
return _textFragment(textBody, htmlOmitted: true);
}
return sanitizeMailHtml(trimmed, imagesShown: imagesShown);
}
SanitizedMailBody _textFragment(String? textBody, {bool htmlOmitted = false}) =>
SanitizedMailBody(
fragment:
'<div class="mail-text">${escapeHtmlText(textBody ?? '')}</div>',
proxiedTokens: const <String>[],
htmlOmitted: htmlOmitted,
);
/// 정화 전 비용 — 태그(여는 · 닫는)마다 그때의 깊이를 더한다. 글자를 한 번만 훑고 [limit] 을 넘으면 그 자리에서 멈춘다.
///
/// 파서보다 **깊게** 센다(넉넉한 쪽 — 덜 세면 깊은 본문이 정화를 오래 붙잡는다):
/// - 암묵 닫힘(`<p>` 뒤의 `<p>` 등)을 보지 않고, 원시 글(`<textarea>` · `<script>` 등) 안의 태그도 센다.
/// - **주석을 건너뛰지 않는다** — 주석 안의 태그도 센다. 주석의 끝을 파서와 다르게 읽으면 그 사이를 덜 센다(서버가 주석을 지우므로 정상 메일과는 상관없다).
/// 문서 형식 선언 · 엉뚱한 주석(`<!x …>` · `<?…>`)만 파서처럼 다음 `>` 까지 건너뛴다.
/// - 빈 요소(`br` · `img` 등)만 깊이를 늘리지 않는다 — `<div/>` 의 `/` 는 파서가 무시하므로 여는 태그로 센다.
/// - 닫는 태그는 맨 위에 열린 요소와 이름이 같을 때만 깊이를 줄인다 — 파서는 맞지 않는 닫는 태그(`<div>` 안의 `</span>`)를 무시하므로,
/// 닫는 태그마다 줄이면 `<div>` 1,000 · `</span>` 1,000 · `<div>` 1,000 의 깊이를 2,000 이 아니라 1,000 으로 덜 센다.
/// 서버가 정화한 본문은 태그를 모두 맞게 닫아 실제 깊이와 같다
int htmlNestingCost(String html, {required int limit}) {
final int length = html.length;
// 열린 요소의 이름 — 깊이는 그 수다(한도를 넘으면 멈추므로 깊이도 한도의 제곱근쯤에서 그친다)
final List<String> open = <String>[];
int cost = 0;
int index = 0;
while (true) {
index = html.indexOf('<', index);
if (index == -1 || index + 1 >= length) break;
final int next = html.codeUnitAt(index + 1);
if (next == _bang || next == _question) {
if (html.startsWith('<!--', index)) {
index += 4;
continue;
}
final int end = html.indexOf('>', index + 2);
if (end == -1) break;
index = end + 1;
continue;
}
if (next == _slash) {
if (index + 2 < length && _isAsciiLetter(html.codeUnitAt(index + 2))) {
cost += open.length;
final int nameEnd = _tagNameEnd(html, index + 2);
if (open.isNotEmpty &&
open.last == html.substring(index + 2, nameEnd).toLowerCase()) {
open.removeLast();
}
}
index += 2;
} else if (_isAsciiLetter(next)) {
final int nameEnd = _tagNameEnd(html, index + 1);
final int close = html.indexOf('>', nameEnd);
if (close == -1) break;
final String name = html.substring(index + 1, nameEnd).toLowerCase();
cost += open.length;
if (!_htmlVoidTags.contains(name)) open.add(name);
index = close + 1;
} else {
index++;
}
if (cost > limit) return cost;
}
return cost;
}
/// 태그 이름이 끝나는 자리 — [start] 부터 이름 글자([_isTagNameChar])가 이어지는 데까지
int _tagNameEnd(String html, int start) {
int end = start;
while (end < html.length && _isTagNameChar(html.codeUnitAt(end))) {
end++;
}
return end;
}
/// 화면 isolate 에서 그 자리 정화하는 서식 본문의 최대 길이(글자) — 이 길이부터는 다른 isolate 에서 정화한다.
///
/// 그 자리 정화가 2ms 안쪽이 되게 잡았다(2026-11-06 개발 PC · AOT 로 잰 중앙값 — 휴대폰은 몇 배 느리다):
/// 태그가 촘촘한 평평한 본문 6,000자 1.3ms · 8,000자 1.7ms · 10,000자 2.0~2.5ms · 32,000자 7ms, 인라인 style 표 32,000자 4ms.
/// 다른 isolate 로 보내는 값은 작은 본문 한 통에 0.8ms(결과까지의 시간 — 화면 isolate 는 그동안 비어 있다)
const int kMailBodyInlineMaxLength = 6 * 1024;
/// 그 자리 정화의 최대 중첩 비용([htmlNestingCost]) — 짧아도 깊은 본문은 파서가 깊이의 제곱으로 일한다.
///
/// 같은 PC 에서: `div` 중첩 비용 10,000 0.6ms · 25,600 1.3ms · 40,000 1.9ms · 90,000 4.3ms · 200,000 남짓 10~14ms,
/// 인용(`blockquote` + 문단) 중첩 20,200 1.4ms · 29,040 2.0ms. 길이 한도(6,000자 1.3ms)와 함께 2ms 안쪽이 되게 5,000 으로 둔다
const int kMailBodyInlineMaxCost = 5000;
/// 화면 isolate 에서 그 자리 만드는 글자 본문(이스케이프만)의 최대 길이 — 같은 PC 에서 64,000자 1.0ms · 128,000자 2.4ms
const int kMailBodyInlineMaxTextLength = 64 * 1024;
/// 본문 조각([mailBodyFragment])을 만들 곳
enum MailBodyBuildPlace {
/// 화면 isolate 에서 그 자리 — 짧고 얕은 본문
inline,
/// 다른 isolate 에서(`Isolate.run`) — 길거나 깊은 본문
isolate,
}
/// 본문 조각을 만들 곳 — 서식 본문은 [kMailBodyInlineMaxLength] 보다 짧고 **중첩 비용도** [kMailBodyInlineMaxCost] 안일 때만 그 자리,
/// 글자 본문은 [kMailBodyInlineMaxTextLength] 보다 짧을 때만 그 자리. 비용은 한도를 넘는 순간 멈춰 세므로 고르는 일 자체는 짧다
MailBodyBuildPlace mailBodyBuildPlaceOf({
required String? htmlBody,
required String? textBody,
}) {
final String? html = displayedHtmlBody(htmlBody);
if (html == null) {
return (textBody?.length ?? 0) < kMailBodyInlineMaxTextLength
? MailBodyBuildPlace.inline
: MailBodyBuildPlace.isolate;
}
if (html.length >= kMailBodyInlineMaxLength) {
return MailBodyBuildPlace.isolate;
}
return htmlNestingCost(html, limit: kMailBodyInlineMaxCost) >
kMailBodyInlineMaxCost
? MailBodyBuildPlace.isolate
: MailBodyBuildPlace.inline;
}
const int _bang = 0x21;
const int _question = 0x3F;
const int _slash = 0x2F;
bool _isAsciiLetter(int unit) =>
(unit >= 0x41 && unit <= 0x5A) || (unit >= 0x61 && unit <= 0x7A);
/// 태그 이름의 글자 — 공백 · `/` · `>` 가 아닌 것
bool _isTagNameChar(int unit) =>
unit != 0x20 &&
unit != 0x09 &&
unit != 0x0A &&
unit != 0x0C &&
unit != 0x0D &&
unit != _slash &&
unit != 0x3E;
/// HTML 의 빈 요소 — 닫는 태그가 없어 깊이를 늘리지 않는다
const Set<String> _htmlVoidTags = <String>{
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', //
'meta', 'source', 'track', 'wbr', 'param', 'keygen',
};
/// 메일 HTML 정화 — 결과는 `<body>` 안에 넣을 조각.
///
/// 허용 목록 밖의 태그는 태그만 지우고 글은 남긴다(`<o:p>` · `<form>` · `<button>` 등), [_droppedWithContent] 는 내용째 버린다.
/// HTML 이 아닌 이름공간(SVG · MathML)은 내용째 버린다. 주석은 지운다. 깊은 중첩에도 스택이 넘치지 않게 되풀이로 돈다.
/// 문서 전체로 읽어 `<body>` 아래만 쓴다 — 조각으로 읽으면(`parseFragment`) 맨 위 요소가 많을 때 파서가 옮기며 제곱으로 느려진다
/// (2026-11-06 측정: 맨 위 span 4만 개 10초 → 문서로 0.3초). 머리에 들어가는 태그(meta · title · style 등)는 어차피 버린다
SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
final dom.Element? body = html_parser.parse(html).body;
final StringBuffer out = StringBuffer();
final List<String> tokens = <String>[];
final Set<String> seen = <String>{};
if (body == null) {
return const SanitizedMailBody(fragment: '', proxiedTokens: <String>[]);
}
// 되풀이 깊이 우선 — 닫는 태그는 글로 쌓는다
final List<Object> stack = <Object>[..._reversed(body.nodes)];
while (stack.isNotEmpty) {
final Object item = stack.removeLast();
if (item is String) {
out.write(item);
continue;
}
final dom.Node node = item as dom.Node;
if (node is dom.Text) {
out.write(escapeHtmlText(node.data));
continue;
}
if (node is! dom.Element) continue;
final String? namespace = node.namespaceUri;
if (namespace != null && namespace != _htmlNamespace) continue;
final String name = (node.localName ?? '').toLowerCase();
if (_droppedWithContent.contains(name)) continue;
if (!_allowedTags.contains(name)) {
stack.addAll(_reversed(node.nodes));
continue;
}
out
..write('<')
..write(name)
..write(
_attributesOf(
node,
name,
imagesShown: imagesShown,
onProxied: (String token) {
if (seen.contains(token)) return true;
if (tokens.length >= kMaxProxiedImages) return false;
seen.add(token);
tokens.add(token);
return true;
},
),
)
..write('>');
if (_voidTags.contains(name)) continue;
stack
..add('</$name>')
..addAll(_reversed(node.nodes));
}
return SanitizedMailBody(
fragment: out.toString(),
proxiedTokens: List<String>.unmodifiable(tokens),
);
}
const String _htmlNamespace = 'http://www.w3.org/1999/xhtml';
Iterable<dom.Node> _reversed(List<dom.Node> nodes) => nodes.reversed;
/// 남길 속성을 ` 이름="값"` 꼴로 — 그림은 `src` 를, 링크는 `href` 를 맨 앞에 쓴다.
/// [onProxied] 는 남길 중계 그림이면 참(한 통의 한도를 넘으면 거짓 — 그 그림은 주소를 지운다)
String _attributesOf(
dom.Element element,
String tag, {
required bool imagesShown,
required bool Function(String token) onProxied,
}) {
final Set<String> tagSpecific = _tagAttributes[tag] ?? const <String>{};
final StringBuffer out = StringBuffer();
final Map<String, String> attributes = <String, String>{
for (final MapEntry<Object, String> entry in element.attributes.entries)
if (entry.key is String) (entry.key as String).toLowerCase(): entry.value,
};
// 주소 속성 — 맨 앞에
if (tag == 'img') {
final String? src = _imageSource(
attributes['src'],
imagesShown: imagesShown,
onProxied: onProxied,
);
if (src != null) out.write(' src="${escapeHtmlAttribute(src)}"');
}
if (tag == 'a') {
final String? href = _linkTarget(attributes['href']);
if (href != null) out.write(' href="${escapeHtmlAttribute(href)}"');
}
for (final MapEntry<String, String> entry in attributes.entries) {
final String name = entry.key;
if (name == 'src' || name == 'href') continue;
if (!_globalAttributes.contains(name) && !tagSpecific.contains(name)) {
continue;
}
final String value = entry.value;
final int limit = name == 'style' ? _maxStyleLength : _maxAttributeLength;
if (value.length > limit) continue;
out.write(' $name="${escapeHtmlAttribute(value)}"');
}
return out.toString();
}
/// 그림 주소 — 늘 `data:` 그림, 외부 이미지를 보일 때만 서버의 중계 주소도. 그 밖은 null(주소를 지운다 — 그림은 숨는다)
String? _imageSource(
String? raw, {
required bool imagesShown,
required bool Function(String token) onProxied,
}) {
final String? src = raw?.trim();
if (src == null || src.isEmpty) return null;
if (_dataImage.hasMatch(src)) return src;
if (!imagesShown) return null;
final String? token = proxiedImageToken(src);
if (token == null || !onProxied(token)) return null;
return src;
}
/// 링크 주소 — http · https · mailto 만, 공백 · 제어 문자가 섞인 주소는 지운다
String? _linkTarget(String? raw) {
final String? href = raw?.trim();
if (href == null || href.isEmpty || href.length > _maxHrefLength) {
return null;
}
if (!_safeLink.hasMatch(href)) return null;
if (href.runes.any(_isControlOrSpace)) return null;
return href;
}
bool _isControlOrSpace(int rune) =>
rune <= 0x20 || (rune >= 0x7F && rune <= 0x9F);
/// 중계 주소의 표 — 그 꼴이 아니면 null
String? proxiedImageToken(String src) =>
kProxiedImagePattern.firstMatch(src.trim())?.group(1);
/// 글 이스케이프 — 본문의 글 · 텍스트 본문
String escapeHtmlText(String text) => text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
/// 속성 값 이스케이프 — 따옴표 · 꺾쇠까지(값 안에 `>` 가 남지 않아 [resolveProxiedImages] 가 태그 경계를 믿을 수 있다)
String escapeHtmlAttribute(String value) => value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
/// 정화한 조각의 중계 그림 주소를 받은 그림(`data:`)으로 바꾼다 — 받지 못한(실패 · 한도 밖 · 아직 받는 중) 그림은 주소를 지운다(그림이 숨는다).
///
/// 같은 그림이 여러 번 나오면 나온 만큼 문서에 실린다 — 그래서 실은 그림 글(`data:` 주소의 글자 수)을 **나온 차례대로** 더해
/// [maxTotalLength] 를 넘는 자리부터는 모두 주소를 지운다(뒤의 작은 그림도). 문서를 만드는 도중에 센다 — 같은 4.9MB 그림을
/// `<img>` 1,000번으로 넣은 본문이 6.5GB 문서가 되기 전에 멈춘다.
/// [sanitizeMailHtml] 이 쓴 `<img src="/api/mail-images/<표>"` 꼴만 본다 — 글(이스케이프된 `&lt;img`)과 다른 속성 값에는 닿지 않는다
String resolveProxiedImages(
String fragment,
Map<String, String> dataUris, {
int maxTotalLength = kMailImagesDocumentMaxLength,
}) {
int used = 0;
bool full = false;
return fragment.replaceAllMapped(_proxiedImageTag, (Match match) {
final String? data = dataUris[match.group(1)!];
if (data == null || full) return '<img';
if (used + data.length > maxTotalLength) {
full = true;
return '<img';
}
used += data.length;
return '<img src="${escapeHtmlAttribute(data)}"';
});
}
final RegExp _proxiedImageTag = RegExp(
r'<img src="/api/mail-images/([A-Za-z0-9_-]+)"',
);
/// 본문 문서의 기본 모양 — 앱의 메일 본문 15 · 줄 간격 1.75(앱 디자인 README "크기 · 글꼴"), 글자색 · 강조색 · 인용선은 웹 `MAIL_FRAME_STYLE` 과 같다.
/// 글꼴은 CSP 로 내려받을 수 없어 기기 글꼴을 쓴다. 여백은 보드 `App1Reader` 의 본문(위 16 · 좌우 20 · 아래 20 — 마지막 줄이 막대에 붙지 않게 24)
const String _documentStyle =
'html{-webkit-text-size-adjust:100%;overflow-x:auto}'
"body{margin:0;padding:16px 20px 24px;font-family:Pretendard,'Noto Sans KR','Apple SD Gothic Neo',system-ui,sans-serif;"
'font-size:15px;line-height:1.75;color:#1F2733;background:#FFFFFF;overflow-wrap:anywhere}'
'img{max-width:100%;height:auto}'
// 주소를 지운 그림(외부 이미지 차단 · 받지 못함)은 깨진 그림 대신 숨긴다 — 차단 사실은 본문 위 안내 줄이 알린다
'img:not([src]){display:none}'
'a{color:#0E7C86}'
'pre{white-space:pre-wrap}'
'blockquote{margin:0 0 0 4px;padding-left:12px;border-left:2px solid #98A2B3;color:#4A5363}'
'.mail-text{white-space:pre-wrap}';
/// WebView 에 넣을 문서 — CSP 를 맨 앞에(어떤 내용보다 먼저 걸리게) 둔다
String buildMailDocument(String bodyFragment) => <String>[
'<!doctype html>',
'<html lang="ko"><head>',
'<meta charset="utf-8">',
'<meta http-equiv="Content-Security-Policy" content="$kMailDocumentCsp">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
'<meta name="referrer" content="no-referrer">',
'<style>$_documentStyle</style>',
'</head>',
'<body>$bodyFragment</body>',
'</html>',
].join();
@@ -0,0 +1,71 @@
/// 외부 이미지(서버의 중계 그림)를 받아 본문에 넣을 때의 한도와 판정 — 순수 함수 (계약 2절 "외부 이미지 보기").
///
/// WebView 는 바깥에 나가지 않으므로 앱이 Dio(Bearer)로 `GET /api/mail-images/{표}` 를 받아 `data:` 로 바꿔 넣는다.
/// 서버가 이미 그림인지 보고(앞 바이트 — PNG · JPEG · GIF · WebP) 5MB 에서 끊지만, 앱도 받은 바이트를 다시 본다.
library;
import 'dart:convert';
import 'dart:typed_data';
/// 그림 하나의 최대 크기 — 서버가 바깥에서 받는 한도와 같다(5MB)
const int kMailImageMaxBytes = 5 * 1024 * 1024;
/// 한 통에서 받아 두는 그림 합계의 최대 크기(서로 다른 그림마다 한 번) — 앱에서 정함. 그림은 문서 글(base64 — 4/3 배)에 실려 WebView 로 넘어가므로
/// 메모리를 지키려고 둔다(웹은 브라우저가 그림마다 따로 받아 한도가 없다). 넘는 그림은 자리를 비운다
const int kMailImagesTotalMaxBytes = 10 * 1024 * 1024;
/// 본문 문서에 싣는 그림 글(`data:` 주소의 글자 수)의 합계 한도 — 같은 그림이 여러 번 나오면 **나온 만큼** 센다.
/// 문서 차례대로 싣다가 넘는 자리부터는 주소를 지운다(`resolveProxiedImages`). 같은 그림 하나를 `<img>` 1,000번(HTML 40KB)으로 넣어도
/// 문서가 이 한도 안에 든다 — 받는 양의 한도([kMailImagesTotalMaxBytes])와 같은 수다
const int kMailImagesDocumentMaxLength = 10 * 1024 * 1024;
/// 함께 받는 그림 수 — 서버의 한 직원 한도(6)보다 적게 둔다
const int kMailImageConcurrency = 4;
/// 받은 바이트의 앞부분으로 그림 형식을 정한다 — PNG · JPEG · GIF · WebP 만(서버와 같다). 아니면 null
String? sniffImageType(Uint8List bytes) {
if (_startsWith(bytes, _png)) return 'image/png';
if (_startsWith(bytes, _jpeg)) return 'image/jpeg';
if (_startsWith(bytes, _gif87) || _startsWith(bytes, _gif89)) {
return 'image/gif';
}
if (bytes.length >= _webpHeaderLength &&
_startsWith(bytes, _riff) &&
_matchesAt(bytes, _webp, _webpOffset)) {
return 'image/webp';
}
return null;
}
/// `data:` 그림 주소
String imageDataUri(String mimeType, Uint8List bytes) =>
'data:$mimeType;base64,${base64Encode(bytes)}';
/// 받은 그림을 넣을 수 있는가 — 형식이 그림이고 한도 안이면 그 `data:` 주소, 아니면 null.
/// [usedBytes] 는 이 통에 이미 넣은 그림의 바이트 합
String? acceptMailImage(Uint8List bytes, {required int usedBytes}) {
if (bytes.isEmpty || bytes.length > kMailImageMaxBytes) return null;
if (usedBytes + bytes.length > kMailImagesTotalMaxBytes) return null;
final String? type = sniffImageType(bytes);
return type == null ? null : imageDataUri(type, bytes);
}
const List<int> _png = <int>[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
const List<int> _jpeg = <int>[0xFF, 0xD8, 0xFF];
const List<int> _gif87 = <int>[0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
const List<int> _gif89 = <int>[0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
const List<int> _riff = <int>[0x52, 0x49, 0x46, 0x46];
const List<int> _webp = <int>[0x57, 0x45, 0x42, 0x50];
const int _webpOffset = 8;
const int _webpHeaderLength = 12;
bool _startsWith(Uint8List bytes, List<int> magic) =>
_matchesAt(bytes, magic, 0);
bool _matchesAt(Uint8List bytes, List<int> magic, int offset) {
if (bytes.length < offset + magic.length) return false;
for (int index = 0; index < magic.length; index++) {
if (bytes[offset + index] != magic[index]) return false;
}
return true;
}
@@ -0,0 +1,79 @@
/// 본문 WebView 의 탐색 판정 — 순수 함수 (앱 디자인 README 「읽기」: 링크는 바깥 브라우저 · mailto 는 앱의 쓰기 화면).
///
/// WebView 는 처음 넣은 문서(`about:blank` — `loadHtmlString` 의 기준 주소 없음) 밖으로 가지 않는다. 주 틀에서 누른 링크는 모두 막고
/// 앱이 대신 연다 — http · https 는 바깥 앱(브라우저), mailto 는 앱의 쓰기 화면. 그 밖(javascript: · file: · content: · intent: ·
/// 상대 주소)은 아무것도 하지 않는다. 정화가 http · https · mailto 밖의 링크를 이미 지우지만 여기서 한 번 더 본다.
///
/// **아래 틀(iframe)의 탐색은 이 판정을 거치지 않는다**(안드로이드 — webview_flutter_android 는 주 틀만 넘기고 아래 틀은 WebView 가 그대로 연다).
/// 아래 틀을 막는 것은 정화(iframe · frame · object · embed 를 내용째 버림)와 문서의 CSP(`default-src 'none'` — frame-src 가 이것을 따른다)다.
library;
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
/// WebView 가 처음 연 문서의 주소 — 기준 주소 없이 넣은 문서(Android `loadDataWithBaseURL(null, …)` · iOS `loadHTMLString`)
const String kInitialDocumentUrl = 'about:blank';
/// 바깥으로 보낼 링크 주소의 최대 길이 — 정화의 링크 한도와 같다
const int kMaxExternalLinkLength = 2048;
/// 탐색 하나의 판정
sealed class MailNavigation {
const MailNavigation();
}
/// 처음 넣은 문서 — 그대로 연다
final class OpenInitialDocument extends MailNavigation {
const OpenInitialDocument();
}
/// 막고 바깥 앱(브라우저)으로 — http · https
final class OpenExternally extends MailNavigation {
const OpenExternally(this.uri);
final Uri uri;
}
/// 막고 앱의 쓰기 화면으로 — mailto
final class OpenCompose extends MailNavigation {
const OpenCompose(this.prefill);
final ComposePrefill prefill;
}
/// 막고 아무것도 하지 않는다
final class IgnoreNavigation extends MailNavigation {
const IgnoreNavigation();
}
/// WebView 의 탐색 요청([url] · [isMainFrame]) → 판정
MailNavigation mailNavigationOf(String url, {required bool isMainFrame}) {
final String trimmed = url.trim();
if (trimmed == kInitialDocumentUrl) {
return isMainFrame ? const OpenInitialDocument() : const IgnoreNavigation();
}
// 아래 틀 — 안드로이드는 여기까지 오지 않는다(정화와 CSP 가 막는다). 다른 플랫폼이 넘기면 열지 않는다
if (!isMainFrame) return const IgnoreNavigation();
return mailLinkOf(trimmed);
}
/// 본문의 링크 주소 → 판정 (누른 링크)
MailNavigation mailLinkOf(String url) {
if (url.isEmpty || url.length > kMaxExternalLinkLength) {
return const IgnoreNavigation();
}
if (url.runes.any((int rune) => rune < 0x20 || rune == 0x7F)) {
return const IgnoreNavigation();
}
final Uri? uri = Uri.tryParse(url);
if (uri == null) return const IgnoreNavigation();
switch (uri.scheme.toLowerCase()) {
case 'http':
case 'https':
return uri.host.isEmpty ? const IgnoreNavigation() : OpenExternally(uri);
case 'mailto':
final ComposePrefill? prefill = composePrefillOfMailto(uri);
return prefill == null ? const IgnoreNavigation() : OpenCompose(prefill);
default:
return const IgnoreNavigation();
}
}
+28 -10
View File
@@ -1,7 +1,9 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
@@ -19,23 +21,36 @@ abstract final class MailRoutes {
static String message(String messageId) =>
'$home/messages/${Uri.encodeComponent(messageId)}';
/// 메일 쓰기 (전체 화면 — 탭 막대 없이)
/// 메일 쓰기 (전체 화면 — 탭 막대 없이). 본문의 mailto 링크는 채울 값을 라우트의 `extra`([ComposePrefill])로 넘긴다
static String get compose => '$home/compose';
/// 임시 저장본 이어 쓰기 — 임시보관함의 줄을 누르면(웹과 같다 — 주소 `?draft=<id>`)
static String composeDraft(String draftId) => Uri(
path: compose,
queryParameters: <String, String>{draftQuery: draftId},
).toString();
static String composeDraft(String draftId) =>
_compose(<String, String>{ComposeQuery.draft: draftId});
/// 답장 · 전체 답장 — 읽기 화면의 아래 막대(웹과 같다 — `?replyTo=<id>` · `&all=1`)
static String composeReply(String messageId, {bool all = false}) =>
_compose(<String, String>{
ComposeQuery.replyTo: messageId,
if (all) ComposeQuery.replyAll: ComposeQuery.replyAllValue,
});
/// 전달 — 읽기 화면의 아래 막대(웹과 같다 — `?forward=<id>`)
static String composeForward(String messageId) =>
_compose(<String, String>{ComposeQuery.forward: messageId});
static String _compose(Map<String, String> query) =>
Uri(path: compose, queryParameters: query).toString();
/// 이어 쓸 임시 저장본 id 의 쿼리 이름 (웹 `/mail/compose?draft=` 와 같다)
static const String draftQuery = 'draft';
static const String draftQuery = ComposeQuery.draft;
}
/// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
///
/// 목록은 앱 보드 `App1MailList` 화면이다. 읽기 · 쓰기는 아직 자리 화면이다 — 보드(`App1Reader` · `App1Compose`)대로 만들면
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 둔다).
/// 목록은 앱 보드 `App1MailList`, 읽기는 `App1Reader` 화면이다. 쓰기는 아직 자리 화면이다 — 보드(`App1Compose`)대로 만들면
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 — 여는 까닭은 `composeIntentOf(state.uri.queryParameters)`,
/// mailto 로 채울 값은 `state.extra` 의 [ComposePrefill]).
final ModuleRoutes mailModuleRoutes = ModuleRoutes(
id: AppModuleId.mail,
// 알림 · 깊은 링크의 id 는 메일 id 다 — 새 메일 알림("새 메일 N통")처럼 id 가 없으면 첫 화면
@@ -53,8 +68,11 @@ final ModuleRoutes mailModuleRoutes = ModuleRoutes(
GoRoute(
path: 'messages/:messageId',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: MailTexts.message),
builder: (BuildContext _, GoRouterState state) => MessageReaderPage(
// 메일마다 새 화면 — 다른 메일로 바꿔 끼우면 앞 메일의 상태(펼침 · 받기)를 이어 쓰지 않는다
key: ValueKey<String>(state.pathParameters['messageId']!),
messageId: state.pathParameters['messageId']!,
),
),
GoRoute(
path: 'compose',
@@ -9,16 +9,143 @@ library;
import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:arasystem_app/core/utils/korean_text.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
/// 메일 모듈의 화면 이름 — 아직 자리 화면인 읽기 · 쓰기가 쓴다
/// 메일 모듈의 화면 이름 — 아직 자리 화면인 쓰기가 쓴다
abstract final class MailTexts {
/// 메일 읽기 (전체 화면)
static const String message = '메일 읽기';
/// 메일 쓰기 (전체 화면)
static const String compose = '메일 쓰기';
}
/// 읽기 화면 (보드 `App1Reader`). 보드에 없는 상태(붙잡힌 메일 · 본문 안내 · 받지 못함 · 첨부 열기 · 삭제 확인)의 글은
/// 웹 상수(`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts`)를 그대로 옮겼다 — 앱에서 새로 쓴 글은 그 줄에 적었다
abstract final class MailReaderTexts {
// ── 위 막대 ──
static const String back = '목록으로';
static const String delete = '삭제';
/// 휴지통에만 든 메일의 [delete] 대신 — 확인 뒤 영구 삭제
static const String purge = '영구 삭제';
static const String more = '더 보기';
// ── 더 보기 ──
static const String markUnread = '안 읽음 표시';
/// 읽음 처리를 하지 못한 메일 — [markUnread] 대신 (앱에서 새로 씀)
static const String markRead = '읽음 표시';
static const String flag = '중요 표시';
static const String unflag = '중요 해제';
static const String move = '이동';
static const String reportSpam = '스팸 신고';
/// 스팸메일함의 메일 — [reportSpam] 대신
static const String notSpam = '스팸 아님';
/// 중요 표시를 바꾼 뒤 (앱에서 새로 씀 — 읽기 화면에는 별이 없어 알림으로 알린다)
static const String flagged = '중요 표시했습니다.';
static const String unflagged = '중요 표시를 해제했습니다.';
// ── 머리 ──
static const String noSubject = '(제목 없음)';
static const String noSender = '(보낸 사람 없음)';
/// 받는 사람 가운데 지금 직원
static const String me = '나';
/// 받는 사람 줄 — "받는 사람 나 외 1명"
static String recipients(String first, int others) =>
others > 0 ? '받는 사람 $first 외 ${formatCount(others)}명' : '받는 사람 $first';
static const String noRecipients = '받는 사람 없음';
// 머리 펼침의 칸 이름
static const String fromLabel = '보낸 사람';
static const String toLabel = '받는 사람';
static const String ccLabel = '참조';
static const String bccLabel = '숨은참조';
static const String dateLabel = '날짜';
// ── 안내 줄 ──
static const String imagesBlocked =
'외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.';
static const String showImages = '이미지 보기';
/// 그림을 받아 넣는 동안 — 화면 낭독기 (앱에서 새로 씀)
static const String imagesLoading = '이미지를 불러오는 중';
/// 본문 안내 (웹 `MAIL_BODY_NOTICES`)
static String bodyNotice(MailBodyNotice notice) => switch (notice) {
MailBodyNotice.htmlOmitted => '본문이 복잡해 서식 없이 글자만 표시합니다.',
MailBodyNotice.unavailable => '본문이 너무 크거나 복잡해 표시할 수 없습니다.',
MailBodyNotice.truncated => '본문이 길어 앞부분만 표시합니다.',
};
/// 보내기 취소를 기다리는 메일 (앱에서 새로 씀 — 웹 읽기 화면에는 이 줄이 없다)
static const String sendingNotice = '곧 보낼 메일입니다.';
/// 예약한 메일 — "11월 7일 (토) 오전 9:00 에 보낼 메일입니다." (보드 그대로 띄어 쓴다)
static String scheduledNotice(String time) => '$time 에 보낼 메일입니다.';
static const String undoSend = '보내기 취소';
static const String cancelSchedule = '예약 취소';
static const String undoSendDone = '보내기를 취소하고 임시보관함으로 옮겼습니다.';
static const String scheduleCanceled = '예약을 취소하고 임시보관함으로 옮겼습니다.';
// ── 보내지 않은 메일의 [삭제] 확인 (처음 초점은 [취소]) ──
static const String heldDeleteTitle = '보내지 않은 메일을 삭제할까요?';
static const String heldDeleteMessage = '보내지 않고 휴지통으로 옮깁니다.';
static const String heldDeleteConfirm = '삭제';
static const String heldDeleteCancel = '취소';
static const String heldDeleted = '보내지 않고 휴지통으로 옮겼습니다.';
// ── 본문 ──
/// 본문 자리 — 화면 낭독기
static const String body = '메일 본문';
/// 처음 불러오는 동안 — 화면 낭독기
static const String loading = '메일 불러오는 중';
/// 본문을 정화해 문서로 만드는 동안 본문 자리의 진행 표시 — 화면 낭독기 (앱에서 새로 씀)
static const String bodyLoading = '본문을 불러오고 있습니다.';
/// 본문의 링크를 열 앱이 없을 때 (앱에서 새로 씀)
static const String linkFailed = '이 링크를 열 수 있는 앱이 없습니다.';
// ── 첨부 ──
static String attachmentsTitle(int count) => '첨부 파일 ${formatCount(count)}개';
/// 받기 단추 — 화면 낭독기 "11월_정기점검_안내.pdf 받기"
static String download(String name) => '$name 받기';
static String cancelDownload(String name) => '$name 받기 취소';
/// 진행 막대 — 화면 낭독기
static String downloading(String name) => '$name 받는 중';
/// 진행 글 — "받는 중 64%" (전체 크기를 모르면 "받는 중")
static String progress(int? percent) =>
percent == null ? '받는 중' : '받는 중 $percent%';
/// 대용량 첨부 줄의 크기 옆 (앱에서 새로 씀 — 첫 판은 받지 않는다)
static const String largeFile = '대용량 첨부';
static const String largeFilesNote =
'대용량 첨부는 앱에서 아직 받을 수 없습니다. 본문의 링크를 누르거나 웹메일에서 받아 주세요.';
// 받은 파일을 열지 못했을 때 (앱에서 새로 씀)
static const String noAppToOpen =
'이 파일을 열 수 있는 앱이 없습니다. 파일을 여는 앱을 설치하거나 웹메일에서 받아 주세요.';
static const String openFailed = '파일을 열지 못했습니다. 다시 받아 주세요.';
static const String saveFailed = '파일을 저장하지 못했습니다. 휴대폰의 저장 공간을 확인해 주세요.';
/// 앱에서 열지 않는 형식(웹 문서 · SVG · 설치 파일 · 인증서 — `isBlockedFileOpen`)의 [받기] — 받기 전에 알린다
static const String blockedFileType =
'이 형식의 파일은 앱에서 열 수 없습니다. PC 웹메일에서 받아 주세요.';
// ── 아래 막대 ──
/// 아래 막대 — 화면 낭독기
static const String actions = '답장과 전달';
static const String reply = '답장';
static const String replyAll = '전체 답장';
static const String forward = '전달';
}
/// 목록 화면 (보드 `App1MailList` 「받은편지함」 · 「당겨서 새로고침」 · 「빈 메일함 (휴지통)」)
abstract final class MailListTexts {
// ── 위 막대 ──
@@ -163,6 +290,17 @@ abstract final class MailBulkTexts {
skipped,
);
/// 스팸 아님 — "메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다." (웹 `notSpamMovedText`)
static String notSpamMoved(
int done,
String mailboxName, {
int skipped = 0,
}) => _withSkipped(
'메일 ${formatCount(done)}통을 스팸이 아닌 메일로 표시해 $mailboxName${directionParticle(mailboxName)} 옮겼습니다.',
done,
skipped,
);
static String purged(int done, {int skipped = 0}) =>
_withSkipped('메일 ${formatCount(done)}통을 영구 삭제했습니다.', done, skipped);
@@ -0,0 +1,67 @@
/// 읽기 화면 머리의 표시 — 순수 함수 (보드 `App1Reader` 의 머리: 보낸 사람 · 시각 · "받는 사람 나 외 1명" · 펼친 칸).
library;
import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
/// 머리의 시각 — 오늘 "오전 10:42" · 어제 "어제 오후 4:20" · 올해 "11월 3일 오후 4:20" · 그 전 "2025. 10. 24. 오후 4:20".
/// 기기 시간대로 본다(목록의 `formatMailListTime` 에 시각을 붙인 꼴)
String formatReaderTime(DateTime at, DateTime now) {
final DateTime local = at.toLocal();
final DateTime today = now.toLocal();
final String time = koreanTimeOfDay(local);
if (isSameCalendarDay(local, today)) return time;
if (isSameCalendarDay(local, previousCalendarDay(today))) {
return '어제 $time';
}
if (local.year == today.year) return '${koreanMonthDay(local)} $time';
return '${koreanNumericDate(local)} $time';
}
/// 머리 펼침의 날짜 — "2026년 11월 4일 (수) 오후 4:20"
String formatReaderFullDate(DateTime at) => koreanFullDateTime(at.toLocal());
/// 받는 사람 줄 — 받는 사람 · 참조 · 숨은참조를 합쳐(같은 주소는 한 번) 지금 직원이 있으면 "나"를 앞에.
/// "받는 사람 나" · "받는 사람 나 외 1명" · "받는 사람 박준호 외 2명" · 아무도 없으면 "받는 사람 없음"
String recipientSummaryOf(MessageDetail detail, {required String? myEmail}) {
final List<MailAddress> everyone = <MailAddress>[
...detail.summary.to,
...detail.cc,
...detail.bcc,
];
final Set<String> seen = <String>{};
final List<MailAddress> unique = <MailAddress>[
for (final MailAddress address in everyone)
if (seen.add(address.email.toLowerCase())) address,
];
if (unique.isEmpty) return MailReaderTexts.noRecipients;
final String? me = myEmail?.toLowerCase();
final bool includesMe = me != null && seen.contains(me);
final String first = includesMe
? MailReaderTexts.me
: unique.first.displayName;
return MailReaderTexts.recipients(first, unique.length - 1);
}
/// 머리 펼침의 한 칸 — 이름 · 주소 (이름이 없으면 주소만)
typedef ReaderAddressLine = ({String name, String? email});
/// 주소 하나 → 펼친 칸의 줄. 이름이 없거나 주소와 같으면 주소만 한 줄
ReaderAddressLine readerAddressLine(MailAddress address) {
final String? name = address.name?.trim();
if (name == null || name.isEmpty || name == address.email) {
return (name: address.email, email: null);
}
return (name: name, email: address.email);
}
/// 붙잡힌 메일의 안내 줄 글 — 예약 "11월 7일 (토) 오전 9:00 에 보낼 메일입니다." · 보내기 취소를 기다리는 동안 "곧 보낼 메일입니다."
String pendingNoticeOf(PendingSend pending) =>
pending.kind == PendingSendKind.scheduled
? MailReaderTexts.scheduledNotice(
koreanScheduleTime(pending.sendAt.toLocal()),
)
: MailReaderTexts.sendingNotice;
@@ -0,0 +1,506 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/features/mail/data/mail_link_launcher.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
import 'package:arasystem_app/features/mail/domain/mail_links.dart';
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
import 'package:arasystem_app/features/mail/mail_routes.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_move_sheet.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_attachments.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_header.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_notices.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 머리(제목 · 보낸 사람 · 안내 줄 · 첨부)가 차지할 수 있는 최대 높이 — 남은 자리의 이만큼. 넘으면 머리 안에서 스크롤한다
const double kReaderHeadMaxFraction = 0.5;
/// 메일 읽기 — 앱 보드 `App1Reader` (앱 디자인 README 「읽기」). 탭 막대 없는 전체 화면이다.
///
/// - 위 막대: 뒤로 · 삭제(휴지통에만 든 메일은 빨간 영구 삭제) · 더 보기(안 읽음 표시 · 중요 표시 · 이동 · 스팸 신고 — 스팸메일함에서는 스팸 아님).
/// 붙잡힌 메일(보내기 취소를 기다림 · 예약)에는 이동 · 스팸이 없고 [삭제]는 확인 뒤 발송을 취소하고 휴지통으로.
/// - 머리는 앱 화면, 본문은 WebView(`MailBodyView`) — 머리 아래 남은 자리를 다 쓰고 본문은 그 안에서 스크롤한다.
/// 머리가 길면(펼친 받는 사람 · 첨부 여럿) 남은 자리의 절반까지만 쓰고 머리 안에서 스크롤한다.
/// - **첨부 목록은 머리 안(안내 줄 아래 · 본문 위)이다** — 보드는 본문 아래지만, 본문이 WebView 안에서 스크롤하므로 아래에 두면
/// 늘 화면 아래 자리를 차지한다(쓰기 화면처럼 위에 둔다 — 앱에서 정함).
/// - 아래 행동 막대: 답장 · 전체 답장 · 전달 — 쓰기 화면(다음 차례)으로 그 메일 id 를 넘긴다(`MailRoutes.composeReply` 등). 예약한 메일에는 없다(웹과 같다).
/// - 옮기기 · 스팸 · 휴지통 · 영구 삭제 · 발송 취소가 끝나면 목록으로 돌아간다(목록 · 메일함 개수는 컨트롤러가 맞춘다).
/// 그 일을 하는 동안은 아래 막대와 본문 링크(mailto 포함)를 막고, 돌아가기는 읽기 화면이 맨 위일 때만 한다(위에 연 화면을 닫지 않게).
class MessageReaderPage extends ConsumerStatefulWidget {
const MessageReaderPage({required this.messageId, super.key});
/// 주소의 메일 id
final String messageId;
@override
ConsumerState<MessageReaderPage> createState() => _MessageReaderPageState();
}
class _MessageReaderPageState extends ConsumerState<MessageReaderPage> {
/// 보내지 않은 메일의 삭제 확인 창이 열려 있다 — 그사이 발송이 풀리면 닫는다(보낸 메일의 삭제 확인으로 바뀌지 않게 — 웹과 같다)
bool _heldDialogOpen = false;
MessageReaderController get _controller =>
ref.read(messageReaderProvider(widget.messageId).notifier);
/// 목록으로 — 쌓아 연 화면이면 뒤로, 주소로 바로 열었으면 받은편지함으로.
/// 읽기 화면이 맨 위일 때만 닫는다 — 일이 끝나기를 기다리는 사이 위에 다른 화면이 열렸으면 그 화면을 닫지 않는다
void _leave() {
if (!mounted) return;
if (!(ModalRoute.of(context)?.isCurrent ?? false)) return;
if (context.canPop()) {
context.pop();
} else {
context.go(MailRoutes.home);
}
}
void _notice(String text) => ref.read(mailNoticeSinkProvider)(text);
// ── 위 막대 ──
Future<void> _delete(MessageDetail detail, MailboxesState mailboxes) async {
if (detail.summary.isHeld) {
_heldDialogOpen = true;
final bool confirmed = await showConfirmDialog(
context,
title: MailReaderTexts.heldDeleteTitle,
message: MailReaderTexts.heldDeleteMessage,
confirmLabel: MailReaderTexts.heldDeleteConfirm,
cancelLabel: MailReaderTexts.heldDeleteCancel,
danger: true,
);
_heldDialogOpen = false;
if (!confirmed || !mounted) return;
if (await _controller.deleteHeld()) _leave();
return;
}
if (deletesPermanently(
detail.summary,
mailboxes.byRole(MailboxRole.trash),
)) {
final bool confirmed = await showConfirmDialog(
context,
title: MailBulkTexts.purgeTitle(1),
message: MailBulkTexts.purgeMessage,
confirmLabel: MailBulkTexts.purgeConfirm,
cancelLabel: MailBulkTexts.purgeCancel,
danger: true,
);
if (!confirmed || !mounted) return;
if (await _controller.purge()) _leave();
return;
}
if (await _controller.moveToTrash()) _leave();
}
Future<void> _onMenu(
ReaderMenuAction action,
MessageDetail detail,
MailboxesState mailboxes,
) async {
switch (action) {
case ReaderMenuAction.toggleRead:
final bool markUnread = detail.summary.isRead;
// [안 읽음 표시]는 나중에 읽을 메일이라 목록으로 돌아간다(흔한 메일 앱과 같다 — 앱에서 정함)
if (await _controller.setRead(!markUnread) && markUnread) _leave();
case ReaderMenuAction.toggleFlag:
await _controller.toggleFlag();
case ReaderMenuAction.move:
final List<MailboxNode> targets = <MailboxNode>[
for (final MailboxNode node in moveTargetsOf(mailboxes.items, null))
if (!detail.summary.mailboxIds.contains(node.mailbox.id)) node,
];
if (targets.isEmpty) return;
final Mailbox? chosen = await showMailboxPickerSheet(
context,
targets: targets,
);
if (chosen == null || !mounted) return;
if (await _controller.moveToMailbox(chosen)) _leave();
case ReaderMenuAction.toggleSpam:
if (await _controller.toggleSpam()) _leave();
}
}
// ── 안내 줄 ──
Future<void> _cancelHeld() async {
if (await _controller.cancelHeld()) _leave();
}
// ── 본문 링크 ──
void _onNavigation(MailNavigation navigation) {
// 삭제 · 이동 · 스팸 등을 하는 동안은 열지 않는다 — 끝나면 목록으로 돌아간다
if (ref.read(messageReaderProvider(widget.messageId)).busy != null) return;
switch (navigation) {
case OpenExternally(:final Uri uri):
unawaited(_openExternally(uri));
case OpenCompose(:final ComposePrefill prefill):
unawaited(context.push<void>(MailRoutes.compose, extra: prefill));
case OpenInitialDocument():
case IgnoreNavigation():
break;
}
}
Future<void> _openExternally(Uri uri) async {
final bool opened = await ref.read(mailLinkLauncherProvider)(uri);
if (!opened && mounted) _notice(MailReaderTexts.linkFailed);
}
// ── 아래 막대 ──
void _compose(String location) => unawaited(context.push<void>(location));
@override
Widget build(BuildContext context) {
final MessageReaderState reader = ref.watch(
messageReaderProvider(widget.messageId),
);
final MailboxesState mailboxes = ref.watch(mailboxesProvider);
// 받은편지함 위에서 연 화면이 사라질 때까지 [되돌리기] 알림을 쥔다(컨트롤러도 쥔다)
ref.listen<MailToast?>(mailToastProvider, (MailToast? _, MailToast? _) {});
ref.listen<MessageReaderState>(messageReaderProvider(widget.messageId), (
MessageReaderState? previous,
MessageReaderState next,
) {
// 확인 창을 연 채 발송이 풀렸다 — 창을 닫는다
if (_heldDialogOpen && (previous?.isHeld ?? false) && !next.isHeld) {
_heldDialogOpen = false;
Navigator.of(context, rootNavigator: true).pop(false);
}
});
final MessageDetail? detail = reader.status == MessageReaderStatus.ready
? reader.detail
: null;
final bool busy = reader.busy != null;
final bool held = detail?.summary.isHeld ?? false;
final bool scheduled =
detail?.summary.pendingSend?.kind == PendingSendKind.scheduled;
return Scaffold(
appBar: ReaderAppBar(
onBack: _leave,
onDelete: detail == null || busy
? null
: () => unawaited(_delete(detail, mailboxes)),
permanentDelete:
detail != null &&
!held &&
deletesPermanently(
detail.summary,
mailboxes.byRole(MailboxRole.trash),
),
menu: detail == null
? const <ReaderMenuItem>[]
: readerMenuOf(detail, mailboxes),
onMenu: detail == null || busy
? null
: (ReaderMenuAction action) =>
unawaited(_onMenu(action, detail, mailboxes)),
),
body: switch (reader.status) {
MessageReaderStatus.loading => const _ReaderSkeleton(),
MessageReaderStatus.failed => _ReaderError(
error: reader.error ?? ApiException.fromCode('SYS_001'),
onRetry: () => unawaited(_controller.retry()),
onLeave: _leave,
),
MessageReaderStatus.ready => _ReaderContent(
messageId: widget.messageId,
reader: reader,
onShowImages: () => unawaited(_controller.showImages()),
onCancelHeld: () => unawaited(_cancelHeld()),
onNavigation: _onNavigation,
),
},
// 삭제 · 이동 · 스팸 등을 하는 동안은 쓰기 화면을 열지 않는다 — 끝나면 목록으로 돌아간다
bottomNavigationBar: detail == null || scheduled
? null
: ReaderActionBar(
onReply: busy
? null
: () => _compose(MailRoutes.composeReply(widget.messageId)),
onReplyAll: busy
? null
: () => _compose(
MailRoutes.composeReply(widget.messageId, all: true),
),
onForward: busy
? null
: () => _compose(MailRoutes.composeForward(widget.messageId)),
),
);
}
}
/// 휴지통에만 든 메일인가 — 그러면 [삭제]가 확인 뒤 영구 삭제다(휴지통과 다른 메일함에 함께 든 메일은 휴지통으로 옮기기 — 웹과 같다).
/// 휴지통을 아직 모르면 아니라고 본다(옮기기 쪽이 해롭지 않다)
bool deletesPermanently(MessageSummary summary, Mailbox? trash) {
if (trash == null || summary.mailboxIds.isEmpty) return false;
return summary.mailboxIds.every((String id) => id == trash.id);
}
/// 더 보기 메뉴 — 읽음 · 중요는 늘, 이동 · 스팸은 붙잡히지 않은 메일이고 옮길 곳을 알 때만
List<ReaderMenuItem> readerMenuOf(
MessageDetail detail,
MailboxesState mailboxes,
) {
final MessageSummary summary = detail.summary;
final Mailbox? junk = mailboxes.byRole(MailboxRole.junk);
final bool inJunk = junk != null && summary.mailboxIds.contains(junk.id);
final bool spamTarget = inJunk
? mailboxes.byRole(MailboxRole.inbox) != null
: junk != null;
final bool canMove = moveTargetsOf(
mailboxes.items,
null,
).any((MailboxNode node) => !summary.mailboxIds.contains(node.mailbox.id));
return <ReaderMenuItem>[
summary.isRead
? const ReaderMenuItem(
ReaderMenuAction.toggleRead,
MailReaderTexts.markUnread,
Icons.mark_email_unread_outlined,
)
: const ReaderMenuItem(
ReaderMenuAction.toggleRead,
MailReaderTexts.markRead,
Icons.drafts_outlined,
),
summary.isFlagged
? const ReaderMenuItem(
ReaderMenuAction.toggleFlag,
MailReaderTexts.unflag,
Icons.star,
)
: const ReaderMenuItem(
ReaderMenuAction.toggleFlag,
MailReaderTexts.flag,
Icons.star_border,
),
if (!summary.isHeld && canMove)
const ReaderMenuItem(
ReaderMenuAction.move,
MailReaderTexts.move,
Icons.drive_file_move_outlined,
),
if (!summary.isHeld && spamTarget)
inJunk
? const ReaderMenuItem(
ReaderMenuAction.toggleSpam,
MailReaderTexts.notSpam,
Icons.inbox_outlined,
)
: const ReaderMenuItem(
ReaderMenuAction.toggleSpam,
MailReaderTexts.reportSpam,
Icons.report_outlined,
),
];
}
/// 받은 메일 — 머리(남은 자리의 절반까지 · 넘으면 그 안에서 스크롤) + 본문 WebView(남은 자리 전부)
class _ReaderContent extends ConsumerWidget {
const _ReaderContent({
required this.messageId,
required this.reader,
required this.onShowImages,
required this.onCancelHeld,
required this.onNavigation,
});
final String messageId;
final MessageReaderState reader;
final VoidCallback onShowImages;
final VoidCallback onCancelHeld;
final void Function(MailNavigation navigation) onNavigation;
@override
Widget build(BuildContext context, WidgetRef ref) {
final MessageDetail detail = reader.detail!;
final Map<String, AttachmentDownload> downloads = ref.watch(
attachmentDownloadsProvider(messageId),
);
final AttachmentDownloadsController downloader = ref.read(
attachmentDownloadsProvider(messageId).notifier,
);
// 시각 표기의 "오늘 · 어제"를 가르는 지금 — 시험은 손 시계로 덮어쓴다
final DateTime now = ref.read(clockProvider)();
final String? myEmail = ref.watch(mailAccountProvider);
final List<MessageAttachment> attachments = detail.listedAttachments;
final String? document = reader.document;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: constraints.maxHeight * kReaderHeadMaxFraction,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
ReaderHeader(detail: detail, myEmail: myEmail, now: now),
ReaderNotices(
detail: detail,
htmlOmitted: reader.htmlOmitted,
busy: reader.busy != null,
imagesLoading: reader.imagesLoading,
onShowImages: onShowImages,
onCancelHeld: onCancelHeld,
),
if (attachments.isNotEmpty || detail.largeFiles.isNotEmpty)
ReaderAttachments(
attachments: attachments,
largeFiles: detail.largeFiles,
downloads: downloads,
onDownload: (MessageAttachment attachment) =>
unawaited(downloader.download(attachment)),
onCancel: (MessageAttachment attachment) =>
downloader.cancel(attachment.id),
)
else
const SizedBox(height: AppSpacing.s4),
],
),
),
),
if (reader.imagesLoading)
Semantics(
label: MailReaderTexts.imagesLoading,
liveRegion: true,
child: const LinearProgressIndicator(
minHeight: AppSize.progressBarHeight,
),
),
Expanded(
child: document == null
? const _BodyLoading()
: MailBodyView(
document: document,
onNavigation: onNavigation,
),
),
],
);
},
);
}
}
/// 본문을 정화해 문서로 만드는 동안(큰 본문은 다른 isolate 에서) — 본문 자리 맨 위의 가는 진행 줄
class _BodyLoading extends StatelessWidget {
const _BodyLoading();
@override
Widget build(BuildContext context) {
return Semantics(
label: MailReaderTexts.bodyLoading,
liveRegion: true,
child: const Align(
alignment: Alignment.topCenter,
child: LinearProgressIndicator(minHeight: AppSize.progressBarHeight),
),
);
}
}
/// 처음 받는 동안 — 제목 · 보낸 사람 · 본문 줄 모양의 회색 막대
class _ReaderSkeleton extends StatelessWidget {
const _ReaderSkeleton();
static const List<double> _widths = <double>[0.7, 0.45, 1, 1, 0.6];
@override
Widget build(BuildContext context) {
final Color bar = Theme.of(context).colorScheme.surfaceContainerHighest;
return Semantics(
label: MailReaderTexts.loading,
liveRegion: true,
child: ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s20,
AppSpacing.s12,
AppSpacing.s20,
0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
for (final double width in _widths) ...<Widget>[
FractionallySizedBox(
widthFactor: width,
child: Container(
height: AppSpacing.s12,
decoration: BoxDecoration(
color: bar,
borderRadius: BorderRadius.circular(AppRadius.xs),
),
),
),
const SizedBox(height: AppSpacing.s16),
],
],
),
),
),
);
}
}
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일은 [목록으로] — 다시 해도 같다)
class _ReaderError extends StatelessWidget {
const _ReaderError({
required this.error,
required this.onRetry,
required this.onLeave,
});
final ApiException error;
final VoidCallback onRetry;
final VoidCallback onLeave;
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: <Widget>[
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: MailListError(
error: error,
onRetry: onRetry,
onLeave: onLeave,
leaveLabel: MailReaderTexts.back,
),
),
),
],
);
}
}
@@ -0,0 +1,286 @@
import 'dart:async';
import 'dart:io';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/error_notifier.dart';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/core/files/file_types.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/storage/token_store_provider.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
/// 받고 있는 첨부 하나
@immutable
class AttachmentDownload {
const AttachmentDownload({required this.received, this.total});
/// 받은 바이트
final int received;
/// 전체 바이트 — 모르면 null
final int? total;
/// 받은 비율(0 ~ 100) — 전체를 모르면 null
int? get percent {
final int? all = total;
if (all == null || all <= 0) return null;
return (received * 100 ~/ all).clamp(0, 100);
}
/// 진행 막대의 값(0 ~ 1) — 전체를 모르면 null(돌기만 한다)
double? get fraction {
final int? value = percent;
return value == null ? null : value / 100;
}
@override
bool operator ==(Object other) =>
other is AttachmentDownload &&
other.received == received &&
other.total == total;
@override
int get hashCode => Object.hash(received, total);
}
/// 이 세션에 받아 둔 첨부 — 같은 첨부를 다시 누르면 받지 않고 바로 연다. 메일을 보는 직원이 바뀌면 받은 파일을 모두 지운다.
///
/// 앱이 살아 있는 동안 둔다(받은 파일 기록). 지난 실행이 남긴 파일은 main 이 앱을 띄울 때 지운다(`DownloadedFileStore.purge`)
final Provider<MailDownloadedFiles> mailDownloadedFilesProvider =
Provider<MailDownloadedFiles>((Ref ref) {
final MailDownloadedFiles files = MailDownloadedFiles(
ref.watch(downloadedFileStoreProvider),
);
ref.listen<String?>(mailAccountProvider, (
String? previous,
String? next,
) {
if (previous != next) unawaited(files.forgetAll());
});
return files;
});
/// 받아 둔 첨부의 기록 — (메일 id, 첨부 id) → 기기 안 경로
class MailDownloadedFiles {
MailDownloadedFiles(this.store);
final DownloadedFileStore store;
final Map<String, String> _paths = <String, String>{};
static String _key(String messageId, String attachmentId) =>
'$messageId\n$attachmentId';
String? pathOf(String messageId, String attachmentId) =>
_paths[_key(messageId, attachmentId)];
void remember(String messageId, String attachmentId, String path) =>
_paths[_key(messageId, attachmentId)] = path;
void forget(String messageId, String attachmentId) =>
_paths.remove(_key(messageId, attachmentId));
/// 기록을 비우고 받은 파일을 모두 지운다 — 로그아웃 · 세션 끝 · 다른 직원
Future<void> forgetAll() {
_paths.clear();
return store.purge();
}
}
/// 한 메일의 첨부 받기 — 메일 id 마다 하나, 읽기 화면이 있는 동안만(autoDispose — 떠나면 받던 것을 그만둔다)
final NotifierProviderFamily<
AttachmentDownloadsController,
Map<String, AttachmentDownload>,
String
>
attachmentDownloadsProvider = NotifierProvider.autoDispose
.family<
AttachmentDownloadsController,
Map<String, AttachmentDownload>,
String
>(AttachmentDownloadsController.new);
/// 첨부 받기 — 계약 2절 `GET /api/messages/{id}/attachments/{attachmentId}` (보드 `App1Reader` 「첨부 받는 중」).
///
/// - [받기]를 누르면 앱 임시 폴더에 받고(진행 %) 다 받으면 다른 앱으로 연다(`FileOpener` — 안드로이드 `ACTION_VIEW`).
/// 이 세션에 받아 둔 파일이 있으면 다시 받지 않고 연다. 열 형식은 확장자에서 먼저 정한다(`fileOpenTypeOf` — 보낸 쪽 형식은 확장자를 모를 때만).
/// - 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 받지 않고 알린다(`isBlockedFileOpen`) — 다른 앱(브라우저)이 스크립트를 돌리지 않게.
/// - [받기 취소]는 받던 것을 그만두고 쓰다 만 파일을 지운다. 화면을 떠나도 그만둔다.
/// - 서버의 거절(없음 등)은 인터셉터가 알렸다. 받는 도중 끊기면 여기서 `SYS_001` 로 알린다(응답이 이미 인터셉터를 지났다).
/// - 401 은 인증 인터셉터가 갱신한 뒤 다시 보낸다. 세션이 바뀌었으면 받은 파일을 지우고 열지 않는다.
class AttachmentDownloadsController
extends Notifier<Map<String, AttachmentDownload>> {
AttachmentDownloadsController(this.messageId);
final String messageId;
final Map<String, CancelToken> _cancels = <String, CancelToken>{};
@override
Map<String, AttachmentDownload> build() {
ref.watch(mailAccountProvider);
_cancelAll();
ref.onDispose(_cancelAll);
return const <String, AttachmentDownload>{};
}
void _cancelAll() {
for (final CancelToken token in _cancels.values) {
token.cancel();
}
_cancels.clear();
}
/// [받기] — 받는 중이면 아무것도 하지 않는다. 앱에서 열지 않는 형식(`isBlockedFileOpen` — 웹 문서 · SVG · 설치 파일 · 인증서)은
/// 받기 전에 알리고 받지 않는다
Future<void> download(MessageAttachment attachment) async {
final Ref ref = this.ref;
if (!ref.mounted || state.containsKey(attachment.id)) return;
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
if (isBlockedFileOpen(attachment.name, attachment.type)) {
notice(MailReaderTexts.blockedFileType);
return;
}
final MailDownloadedFiles files = ref.read(mailDownloadedFilesProvider);
final MailRepository repository = ref.read(mailRepositoryProvider);
final FileOpener opener = ref.read(fileOpenerProvider);
final ErrorNotifier errors = ref.read(errorNotifierProvider);
final TokenStore tokens = ref.read(tokenStoreProvider);
final int session = tokens.sessionGeneration;
final String? saved = files.pathOf(messageId, attachment.id);
if (saved != null) {
if (await files.store.exists(saved)) {
if (tokens.sessionGeneration != session) return;
await _open(
saved,
attachment,
opener: opener,
files: files,
notice: notice,
);
return;
}
files.forget(messageId, attachment.id);
}
final CancelToken cancel = CancelToken();
_cancels[attachment.id] = cancel;
_setProgress(attachment.id, 0, _sizeOf(attachment));
DownloadTarget? target;
try {
target = await files.store.create(attachment.name);
await repository.downloadAttachment(
messageId: messageId,
attachmentId: attachment.id,
target: target,
cancelToken: cancel,
onProgress: (int received, int? total) {
if (ref.mounted && !cancel.isCancelled) {
_setProgress(attachment.id, received, total ?? _sizeOf(attachment));
}
},
);
await target.close();
} on DioException catch (error) {
await target?.discard();
_finish(attachment.id, cancel);
if (error.type == DioExceptionType.cancel) return;
// 받는 도중 끊겼다 — 응답은 인터셉터를 지나 아무도 알리지 않았다
if (error is DownloadInterrupted && tokens.sessionGeneration == session) {
errors.show(ApiException.fromCode('SYS_001'));
}
return;
} on FileSystemException {
await target?.discard();
_finish(attachment.id, cancel);
if (tokens.sessionGeneration == session) {
notice(MailReaderTexts.saveFailed);
}
return;
}
_finish(attachment.id, cancel);
// 그사이 세션이 끝났거나 다른 직원이 들어왔다 — 받은 파일을 남기지 않는다
if (tokens.sessionGeneration != session) {
await target.discard();
return;
}
files.remember(messageId, attachment.id, target.path);
// 화면을 떠났으면 열지 않는다 — 받은 파일은 다음에 누르면 바로 연다
if (!ref.mounted || cancel.isCancelled) return;
await _open(
target.path,
attachment,
opener: opener,
files: files,
notice: notice,
);
}
/// [받기 취소]
void cancel(String attachmentId) {
if (!ref.mounted) return;
_cancels.remove(attachmentId)?.cancel();
if (!state.containsKey(attachmentId)) return;
state = <String, AttachmentDownload>{...state}..remove(attachmentId);
}
Future<void> _open(
String path,
MessageAttachment attachment, {
required FileOpener opener,
required MailDownloadedFiles files,
required void Function(String text) notice,
}) async {
// 열 형식은 확장자에서 먼저 — 보낸 쪽이 정한 형식은 확장자를 모를 때만(`fileOpenTypeOf`)
final FileOpenResult result = await opener.open(
path: path,
mimeType: fileOpenTypeOf(attachment.name, attachment.type),
);
switch (result) {
case FileOpenResult.opened:
return;
case FileOpenResult.noApp:
notice(MailReaderTexts.noAppToOpen);
case FileOpenResult.notFound:
case FileOpenResult.failed:
files.forget(messageId, attachment.id);
notice(MailReaderTexts.openFailed);
}
}
int? _sizeOf(MessageAttachment attachment) =>
attachment.size > 0 ? attachment.size : null;
/// 진행을 적는다 — 보이는 비율이 바뀔 때만(조각마다 화면을 다시 그리지 않게)
void _setProgress(String attachmentId, int received, int? total) {
final AttachmentDownload next = AttachmentDownload(
received: received,
total: total,
);
final AttachmentDownload? current = state[attachmentId];
if (current != null &&
current.total == next.total &&
current.percent == next.percent &&
next.percent != null) {
return;
}
state = <String, AttachmentDownload>{...state, attachmentId: next};
}
/// 받기 하나가 끝났다 — 그 받기([cancel])가 아직 이 첨부의 받기일 때만 진행을 지운다.
/// [받기 취소] 바로 뒤에 다시 [받기]를 눌렀으면 앞 받기의 끝(취소)이 새 받기의 진행을 지우지 않는다
void _finish(String attachmentId, CancelToken cancel) {
if (!identical(_cancels[attachmentId], cancel)) return;
_cancels.remove(attachmentId);
if (!ref.mounted || !state.containsKey(attachmentId)) return;
state = <String, AttachmentDownload>{...state}..remove(attachmentId);
}
}
@@ -317,6 +317,7 @@ class MailListController extends Notifier<MailListState> {
void _onMailChange(MailChange change) {
if (identical(change.source, this)) return;
_applyLocalChanges(change);
if (_visible) {
unawaited(_sync());
} else {
@@ -324,6 +325,44 @@ class MailListController extends Notifier<MailListState> {
}
}
/// 읽기 화면이 바꾼 메일을 서버에 다시 묻기 전에 바로 바꾼다 — 읽음 · 중요는 그 줄을 바꾸고,
/// 이 메일함을 떠난 메일(옮기기 · 스팸 · 휴지통)과 뺀 메일(영구 삭제 · 발송 취소)은 뺀다. 그 전에 떠난 맞추기 응답은 버린다
void _applyLocalChanges(MailChange change) {
if (!change.hasLocalChanges || state.status != MailListStatus.ready) return;
final String? mailboxId = state.mailboxId;
final Map<String, MessageSummary> updated = <String, MessageSummary>{
for (final MessageSummary message in change.updated) message.id: message,
};
bool changed = false;
final List<MessageSummary> kept = <MessageSummary>[];
for (final MessageSummary message in state.items) {
if (change.removedIds.contains(message.id)) {
changed = true;
continue;
}
final MessageSummary? next = updated[message.id];
if (next == null) {
kept.add(message);
continue;
}
changed = true;
if (mailboxId != null && !next.mailboxIds.contains(mailboxId)) continue;
kept.add(next);
}
if (!changed) return;
final int removed = state.items.length - kept.length;
final MailPageInfo? info = state.pageInfo;
final int? total = info?.total;
_localGeneration++;
state = state.withItems(
_withPendingFlags(kept),
pageInfo: info != null && total != null && removed > 0
? info.copyWith(total: math.max(0, total - removed))
: info,
);
_afterItemsChanged();
}
/// 보이는 목록을 지금까지 불러온 만큼(최대 100통) 다시 받아 맞춘다 — 고른 메일 · 스크롤 위치는 그대로(계약 2절 "새 메일 확인").
/// 한 번에 하나 — 도는 동안 또 부르면 끝난 뒤 한 번 더 맞추고, 돌려주는 Future 는 그것까지 끝나야 끝난다.
/// 실패는 알리지 않는다(다음 확인에서 다시 맞춘다)
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/utils/app_foreground.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
@@ -17,7 +18,12 @@ const Duration kMailStateRecheckGap = Duration(seconds: 10);
/// 메일이 바뀌었다는 소식 — 목록 · 메일함 개수가 듣는다
@immutable
class MailChange {
const MailChange(this.seq, {this.source});
const MailChange(
this.seq, {
this.source,
this.updated = const <MessageSummary>[],
this.removedIds = const <String>{},
});
/// 소식마다 하나씩 오른다 — 처음부터 다시 만들면 0
final int seq;
@@ -25,14 +31,31 @@ class MailChange {
/// 이 앱의 목록이 스스로 바꾼 것이면 그 목록(그 목록은 이미 맞췄다) — 서버에서 알아챈 바뀜 · 되돌리기는 null
final Object? source;
/// 읽기 화면이 바꾼 메일의 요약(`PATCH /api/messages/{id}` 의 응답에서 그 요청이 바꾼 칸만 읽기 화면의 요약에 덮은 것) — 목록은 서버에 다시 묻기 전에 그 줄을 바로 바꾼다
/// (그 메일함을 떠났으면 뺀다). 맞추기는 그대로 한다
final List<MessageSummary> updated;
/// 읽기 화면이 영구 삭제 · 발송 취소로 뺀 메일 — 목록은 바로 뺀다
final Set<String> removedIds;
/// 목록이 바로 바꿀 것이 있다
bool get hasLocalChanges => updated.isNotEmpty || removedIds.isNotEmpty;
@override
bool operator ==(Object other) =>
other is MailChange &&
other.seq == seq &&
identical(other.source, source);
identical(other.source, source) &&
listEquals(other.updated, updated) &&
setEquals(other.removedIds, removedIds);
@override
int get hashCode => Object.hash(seq, identityHashCode(source));
int get hashCode => Object.hash(
seq,
identityHashCode(source),
Object.hashAll(updated),
Object.hashAllUnordered(removedIds),
);
}
/// 새 메일 확인 — 메일 목록이 보이는 동안만 돈다. 세션이 바뀌면 기준을 버린다.
@@ -104,10 +127,20 @@ class MailStateWatcher extends Notifier<MailChange> {
if (before != _active) _onActiveChanged();
}
/// 이 앱이 메일을 바꿨다 — 다른 목록 · 메일함 개수가 맞추게 알린다. [source] 는 이미 맞춘 목록(없으면 모든 목록)
void markChanged({Object? source}) {
/// 이 앱이 메일을 바꿨다 — 다른 목록 · 메일함 개수가 맞추게 알린다. [source] 는 이미 맞춘 목록(없으면 모든 목록).
/// 읽기 화면은 바꾼 메일의 요약([updated]) · 뺀 메일([removedIds])을 실어 목록이 바로 바꾸게 한다
void markChanged({
Object? source,
List<MessageSummary> updated = const <MessageSummary>[],
Set<String> removedIds = const <String>{},
}) {
if (!ref.mounted) return;
state = MailChange(state.seq + 1, source: source);
state = MailChange(
state.seq + 1,
source: source,
updated: List<MessageSummary>.unmodifiable(updated),
removedIds: Set<String>.unmodifiable(removedIds),
);
}
void _onActiveChanged() {
@@ -0,0 +1,771 @@
import 'dart:async';
import 'dart:isolate';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/storage/token_store_provider.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
/// 본문 조각을 만드는 일 — 정화([mailBodyFragment]). 시험은 덮어쓸 수 있다
typedef MailBodyBuilder =
Future<SanitizedMailBody> Function({
required String? htmlBody,
required String? textBody,
required bool imagesShown,
});
/// 본문 조각 만들기 — 짧고 얕은 본문은 그 자리에서, 길거나 깊은 본문은 [Isolate.run] 으로([mailBodyBuildPlaceOf])
final Provider<MailBodyBuilder> mailBodyBuilderProvider =
Provider<MailBodyBuilder>((Ref ref) => buildMailBody);
/// [mailBodyBuilderProvider] 의 기본 — 2MB 까지 오는 본문 · 짧아도 깊은 본문을 정화하는 동안 화면이 멈추지 않게 곳을 고른다.
/// 처음 받은 본문과 [이미지 보기]로 다시 받은 본문이 같은 길을 간다
Future<SanitizedMailBody> buildMailBody({
required String? htmlBody,
required String? textBody,
required bool imagesShown,
}) async {
SanitizedMailBody build() => mailBodyFragment(
htmlBody: htmlBody,
textBody: textBody,
imagesShown: imagesShown,
);
final MailBodyBuildPlace place = mailBodyBuildPlaceOf(
htmlBody: htmlBody,
textBody: textBody,
);
if (place == MailBodyBuildPlace.inline) return build();
return Isolate.run(build);
}
/// 메일 한 통의 읽기 화면 — 주소의 메일 id 마다 하나. 화면이 있는 동안만 둔다(autoDispose) — 다른 메일을 열면 앞 응답은 버린다.
final NotifierProviderFamily<
MessageReaderController,
MessageReaderState,
String
>
messageReaderProvider = NotifierProvider.autoDispose
.family<MessageReaderController, MessageReaderState, String>(
MessageReaderController.new,
);
/// 읽기 화면 — 계약 2절 `GET /api/messages/{id}` · 읽음 · 중요 · 옮기기 · 스팸 · 삭제 · 9단계 "보내기 취소 · 예약 발송" · "외부 이미지 보기"
/// (웹 `stores/mail/message.ts` · `useMessageRemoval` · `useScheduledMessageActions` 와 같은 규칙)
///
/// - 열면 안 읽은 메일을 읽음으로 바꾼다(화면 · 메일함 개수를 먼저 — 실패하면 알리지 않고 되돌린다).
/// - 본문은 두 번째 정화 뒤 CSP 를 건 문서로 만든다(`mail_html.dart` — 짧고 얕은 본문만 그 자리에서, 길거나 깊은 본문은 다른 isolate 에서).
/// 외부 이미지를 보이는 본문이면(설정 · [이미지 보기]) 중계 그림을 Dio 로 받아(한 번에 [kMailImageConcurrency] 개 · 한도 `mail_images.dart`)
/// `data:` 로 넣고 문서를 한 번 더 만든다. 그림 받기는 화면이 사라지거나 본문이 바뀌면 끊는다(끊긴 그림은 숨기고 알리지 않는다).
/// - 옮기기 · 스팸 · 휴지통은 응답을 받은 뒤 목록으로 돌아가고(부르는 쪽이), 알림에 [되돌리기](원래 메일함 — 스팸은 표시도 반대로).
/// 휴지통에만 든 메일은 확인 뒤 영구 삭제(`POST /api/message-deletions`) — `DELETE` 는 쓰지 않는다(웹과 같다).
/// - 붙잡힌 메일(보내기 취소를 기다림 · 예약)은 옮기거나 스팸으로 보내지 않는다 — [보내기 취소] · [예약 취소]는 임시보관함으로,
/// [삭제]는 확인 뒤 취소하고 휴지통으로. 보낼 시각 + 2초에 다시 보고(서버 시계) 풀렸으면 안내 줄을 지운다.
/// - 늦은 응답: 받기 세대([_seq]) · 본문 세대([_bodySeq])가 바뀌었거나 화면이 사라졌으면 버린다.
/// 세션이 바뀌었으면(토큰 보관의 세션 세대) 알림 · [되돌리기] · 목록 알리기를 하지 않는다.
/// - 응답은 그 요청이 바꾼 칸(읽음 · 중요 · 메일함)만 지금 요약에 덮는다 — 그사이 다른 요청이 바꾼 칸 · 다시 받은 본문을 되돌리지 않게.
/// - 목록 · 메일함 개수는 새 메일 확인의 소식([MailStateWatcher.markChanged] — 그렇게 맞춘 요약을 싣는다)으로 맞춘다.
class MessageReaderController extends Notifier<MessageReaderState> {
MessageReaderController(this.messageId);
/// 주소의 메일 id
final String messageId;
int _seq = 0;
int _bodySeq = 0;
SanitizedMailBody? _body;
Map<String, String> _images = const <String, String>{};
/// 지금 본문의 중계 그림 받기 — 화면이 사라지거나 본문이 바뀌면(다시 받기 · [이미지 보기]) 끊는다
CancelToken? _imagesCancel;
/// 서버 시계 − 기기 시계 — 받은 응답의 `Date` 로 잰다(붙잡아 둔 발송이 풀릴 때를 서버 시계로 잰다)
Duration _serverOffset = Duration.zero;
Timer? _heldTimer;
int _heldRechecks = 0;
/// 연 메일의 읽음 처리 — [setRead] 가 먼저 끝나기를 기다린다(뒤에 보낸 것이 이기게)
Future<void>? _reading;
bool _flagging = false;
MailRepository get _repository => ref.read(mailRepositoryProvider);
Clock get _clock => ref.read(clockProvider);
@override
MessageReaderState build() {
final String? account = ref.watch(mailAccountProvider);
_seq++;
_bodySeq++;
_body = null;
_images = const <String, String>{};
_serverOffset = Duration.zero;
_heldRechecks = 0;
_reading = null;
_flagging = false;
_cancelHeldTimer();
_cancelImages();
ref
..onDispose(_cancelHeldTimer)
..onDispose(_cancelImages);
// [되돌리기] 알림이 이 화면에서 목록으로 돌아가는 동안 사라지지 않게 쥔다(목록 화면이 이어 쥔다)
ref.listen<MailToast?>(mailToastProvider, (MailToast? _, MailToast? _) {});
// 세션이 없으면(로그아웃하며 화면이 사라지는 중) 묻지 않는다
if (account == null) return const MessageReaderState();
unawaited(_load(_seq));
return const MessageReaderState();
}
// ── 받기 ─────────────────────────────────────────
Future<void> _load(int seq) async {
final Ref ref = this.ref;
try {
final ({MessageDetail detail, DateTime? serverTime}) result =
await _repository.getMessage(messageId);
if (!ref.mounted || seq != _seq) return;
_noteServerTime(result.serverTime);
state = MessageReaderState(
status: MessageReaderStatus.ready,
detail: result.detail,
);
_scheduleHeldCheck();
unawaited(_buildBody(result.detail));
if (!result.detail.summary.isRead) _reading = _markReadOnOpen();
} on DioException catch (error) {
if (!ref.mounted || seq != _seq) return;
state = MessageReaderState(
status: MessageReaderStatus.failed,
error: toApiException(error),
);
}
}
/// [다시 시도]
Future<void> retry() async {
if (!ref.mounted) return;
_seq++;
_bodySeq++;
_cancelImages();
state = const MessageReaderState();
await _load(_seq);
}
void _noteServerTime(DateTime? serverTime) {
if (serverTime != null) _serverOffset = serverTime.difference(_clock());
}
DateTime _serverNow() => _clock().add(_serverOffset);
void _setSummary(MessageSummary Function(MessageSummary current) change) {
final MessageDetail? detail = state.detail;
if (detail == null) return;
state = state.copyWith(detail: detail.withSummary(change(detail.summary)));
}
/// 응답([response])에서 그 요청이 바꾼 칸([changes])만 **지금** 요약에 덮는다 — 그사이 다른 요청이 바꾼 칸(중요 · 읽음)과
/// 다시 받은 본문([이미지 보기])을 응답의 옛 값 · 요청 전에 쥔 값으로 되돌리지 않게. 목록에 알릴 요약을 돌려준다(화면이 없으면 응답 그대로)
MessageSummary _applyResponse(
MessageChanges changes,
MessageSummary response,
) {
final MessageDetail? detail = ref.mounted ? state.detail : null;
if (detail == null) return response;
final bool moved = changes.mailboxId != null || changes.isJunk != null;
final MessageSummary merged = detail.summary.copyWith(
isRead: changes.isRead == null ? null : response.isRead,
isFlagged: changes.isFlagged == null ? null : response.isFlagged,
mailboxIds: moved ? response.mailboxIds : null,
);
state = state.copyWith(detail: detail.withSummary(merged));
return merged;
}
// ── 본문 ─────────────────────────────────────────
/// 본문 문서를 만든다 — 정화한 문서를 먼저 보이고, 중계 그림이 있으면 받아 넣은 문서로 한 번 더 바꾼다
Future<void> _buildBody(MessageDetail detail) async {
final Ref ref = this.ref;
final int bodySeq = ++_bodySeq;
// 앞 본문의 그림 받기를 끊는다 — 그 문서는 이제 쓰지 않는다
_cancelImages();
bool alive() => ref.mounted && bodySeq == _bodySeq;
final MailBodyBuilder buildBody = ref.read(mailBodyBuilderProvider);
SanitizedMailBody body;
try {
body = await buildBody(
htmlBody: detail.htmlBody,
textBody: detail.textBody,
imagesShown: detail.imagesShown,
);
} on Object {
// 정화가 뜻밖에 실패하면 서식 없이 글자 본문만 보인다
body = mailBodyFragment(
htmlBody: null,
textBody: detail.textBody,
imagesShown: false,
);
}
if (!alive()) return;
_body = body;
_images = const <String, String>{};
final bool hasImages = body.proxiedTokens.isNotEmpty;
state = state.copyWith(
document: _document(),
bodyOmitted: body.htmlOmitted,
imagesLoading: hasImages,
);
if (!hasImages) return;
final CancelToken cancel = _imagesCancel = CancelToken();
final Map<String, String> loaded = await _fetchImages(
body.proxiedTokens,
alive,
cancel,
);
if (!alive()) return;
_images = loaded;
state = state.copyWith(document: _document(), imagesLoading: false);
}
/// WebView 에 넣을 문서 — 중계 그림은 받은 것만, 문서에 싣는 그림 글은 나온 횟수까지 세어 한도 안에서([resolveProxiedImages])
String? _document() {
final SanitizedMailBody? body = _body;
if (body == null) return null;
return buildMailDocument(
body.proxiedTokens.isEmpty
? body.fragment
: resolveProxiedImages(body.fragment, _images),
);
}
void _cancelImages() {
_imagesCancel?.cancel();
_imagesCancel = null;
}
/// 중계 그림을 받는다 — 한 번에 [kMailImageConcurrency] 개, 그림 하나 · 합계 한도를 넘거나 그림이 아니면 그 자리를 비운다.
/// 실패 · 끊김([cancel] — 화면을 떠남 · 본문이 바뀜)은 알리지 않는다(웹도 받지 못한 그림 자리는 비어 있다)
Future<Map<String, String>> _fetchImages(
List<String> tokens,
bool Function() alive,
CancelToken cancel,
) async {
final MailRepository repository = _repository;
final Map<String, String> loaded = <String, String>{};
int used = 0;
int next = 0;
Future<void> worker() async {
while (next < tokens.length &&
alive() &&
!cancel.isCancelled &&
used < kMailImagesTotalMaxBytes) {
final String token = tokens[next++];
try {
final Uint8List bytes = await repository.fetchMailImage(
token,
maxBytes: kMailImageMaxBytes,
cancelToken: cancel,
);
if (!alive()) return;
final String? data = acceptMailImage(bytes, usedBytes: used);
if (data == null) continue;
loaded[token] = data;
used += bytes.length;
} on DioException {
// 그 자리를 비운다
}
}
}
await Future.wait(<Future<void>>[
for (
int index = 0;
index < math.min(kMailImageConcurrency, tokens.length);
index++
)
worker(),
]);
return loaded;
}
/// [이미지 보기] — 이 메일을 `images=show` 로 다시 받아 본문만 바꾼다(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹과 같다).
/// 다시 열면 설정대로다. 실패는 인터셉터가 알리고 안내 줄은 그대로 남는다
Future<void> showImages() async {
final Ref ref = this.ref;
final MessageDetail? current = state.detail;
if (!ref.mounted ||
current == null ||
current.imagesShown ||
state.imagesLoading) {
return;
}
final int seq = _seq;
state = state.copyWith(imagesLoading: true);
try {
final ({MessageDetail detail, DateTime? serverTime}) result =
await _repository.getMessage(
messageId,
showImages: true,
silent: false,
);
if (!ref.mounted || seq != _seq) return;
final MessageDetail next = (state.detail ?? current).withBodyOf(
result.detail,
);
state = state.copyWith(detail: next);
await _buildBody(next);
} on DioException {
if (!ref.mounted || seq != _seq) return;
state = state.copyWith(imagesLoading: false);
}
}
// ── 읽음 · 중요 ─────────────────────────────────────
/// 연 메일의 읽음 처리 — 화면 · 메일함 개수를 먼저 바꾸고, 실패하면 알리지 않고 되돌린다
Future<void> _markReadOnOpen() async {
final Ref ref = this.ref;
final MessageDetail? detail = state.detail;
if (detail == null || detail.summary.isRead) return;
final MailRepository repository = _repository;
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
final TokenStore tokens = ref.read(tokenStoreProvider);
final int session = tokens.sessionGeneration;
final List<String> boxes = detail.summary.mailboxIds;
_setSummary((MessageSummary s) => s.copyWith(isRead: true));
mailboxes.adjustUnread(boxes, -1);
const MessageChanges changes = MessageChanges(isRead: true);
try {
final MessageSummary summary = await repository.updateMessage(
messageId,
changes,
silent: true,
);
if (tokens.sessionGeneration != session) return;
watcher.markChanged(
updated: <MessageSummary>[_applyResponse(changes, summary)],
);
} on DioException {
if (tokens.sessionGeneration != session) return;
mailboxes.adjustUnread(boxes, 1);
if (ref.mounted) {
_setSummary((MessageSummary s) => s.copyWith(isRead: false));
}
}
}
/// [안 읽음 표시] · [읽음 표시] — 응답을 받은 뒤 화면을 바꾼다. 성공하면 참([안 읽음 표시]면 부르는 쪽이 목록으로 돌아간다)
Future<bool> setRead(bool read) async {
final Ref ref = this.ref;
if (!ref.mounted || state.busy != null || state.detail == null) {
return false;
}
final Future<void>? reading = _reading;
if (reading != null) await reading;
final MessageDetail? detail = state.detail;
if (!ref.mounted || detail == null || state.busy != null) return false;
if (detail.summary.isRead == read) return true;
final MailRepository repository = _repository;
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
final TokenStore tokens = ref.read(tokenStoreProvider);
final int session = tokens.sessionGeneration;
final MessageChanges changes = MessageChanges(isRead: read);
state = state.copyWith(busy: MessageReaderBusy.marking);
try {
final MessageSummary summary = await repository.updateMessage(
messageId,
changes,
);
if (tokens.sessionGeneration != session) return false;
mailboxes.adjustUnread(detail.summary.mailboxIds, read ? -1 : 1);
// 요청 전에 쥔 메일 정보(detail)로 되쓰지 않는다 — 그사이 [이미지 보기]가 본문을 바꿨을 수 있다
watcher.markChanged(
updated: <MessageSummary>[_applyResponse(changes, summary)],
);
if (ref.mounted) state = state.copyWith(clearBusy: true);
return true;
} on DioException {
if (ref.mounted) state = state.copyWith(clearBusy: true);
return false;
}
}
/// [중요 표시] · [중요 해제] — 화면을 먼저 바꾸고 실패하면 되돌린다. 바꾸면 알림(읽기 화면에는 별이 없다)
Future<void> toggleFlag() async {
final Ref ref = this.ref;
final MessageDetail? detail = state.detail;
if (!ref.mounted || detail == null || _flagging) return;
final bool next = !detail.summary.isFlagged;
final MailRepository repository = _repository;
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
final TokenStore tokens = ref.read(tokenStoreProvider);
final int session = tokens.sessionGeneration;
_flagging = true;
_setSummary((MessageSummary s) => s.copyWith(isFlagged: next));
final MessageChanges changes = MessageChanges(isFlagged: next);
try {
final MessageSummary summary = await repository.updateMessage(
messageId,
changes,
);
if (tokens.sessionGeneration != session) return;
watcher.markChanged(
updated: <MessageSummary>[_applyResponse(changes, summary)],
);
notice(next ? MailReaderTexts.flagged : MailReaderTexts.unflagged);
} on DioException {
if (tokens.sessionGeneration != session) return;
if (ref.mounted) {
_setSummary((MessageSummary s) => s.copyWith(isFlagged: !next));
}
} finally {
_flagging = false;
}
}
// ── 옮기기 · 스팸 · 삭제 ─────────────────────────────────
/// 휴지통으로 — 성공하면 참(목록으로 돌아간다). 휴지통을 모르면 메일함 목록을 받아 보고, 그래도 모르면 알린다
Future<bool> moveToTrash() async {
final Mailbox? trash = await _mailboxOfRole(MailboxRole.trash);
if (trash == null) return false;
return _move(trash, text: MailBulkTexts.moved(1, trash.name));
}
/// [이동] — 고른 메일함으로
Future<bool> moveToMailbox(Mailbox target) =>
_move(target, text: MailBulkTexts.moved(1, target.name));
/// [스팸 신고](스팸메일함으로 + 스팸 표시) · [스팸 아님](받은편지함으로 + 스팸 아님 표시) — 그 메일의 메일함으로 정한다
Future<bool> toggleSpam() async {
final MessageDetail? detail = state.detail;
if (detail == null) return false;
final Mailbox? junk = await _mailboxOfRole(MailboxRole.junk);
if (junk == null) return false;
final bool inJunk = detail.summary.mailboxIds.contains(junk.id);
if (!inJunk) {
return _move(junk, isJunk: true, text: MailBulkTexts.moved(1, junk.name));
}
final Mailbox? inbox = await _mailboxOfRole(MailboxRole.inbox);
if (inbox == null) return false;
return _move(
inbox,
isJunk: false,
text: MailBulkTexts.notSpamMoved(1, inbox.name),
);
}
/// 역할의 메일함 — 모르면 메일함 목록을 다시 받아 본다. 그래도 모르면 알리고 null (옮기지 않는다)
Future<Mailbox?> _mailboxOfRole(MailboxRole role) async {
final Ref ref = this.ref;
if (!ref.mounted) return null;
Mailbox? mailbox = ref.read(mailboxesProvider).byRole(role);
if (mailbox != null) return mailbox;
await ref.read(mailboxesProvider.notifier).reload();
if (!ref.mounted) return null;
final MailboxesState mailboxes = ref.read(mailboxesProvider);
mailbox = mailboxes.byRole(role);
if (mailbox == null) {
ref
.read(errorNotifierProvider)
.show(mailboxes.failure ?? ApiException.fromCode('BIZ_001'));
}
return mailbox;
}
/// [target] 으로 옮긴다 — 응답을 받으면 목록에 알리고 알림에 [되돌리기](원래 메일함 · 스팸 표시는 반대로)
Future<bool> _move(
Mailbox target, {
required String text,
bool? isJunk,
}) async {
final Ref ref = this.ref;
final MessageDetail? detail = state.detail;
if (!ref.mounted ||
detail == null ||
state.busy != null ||
detail.summary.isHeld) {
return false;
}
final MailRepository repository = _repository;
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
final MailToastNotifier toast = ref.read(mailToastProvider.notifier);
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
final TokenStore tokens = ref.read(tokenStoreProvider);
final int session = tokens.sessionGeneration;
final String? origin = _originOf(detail.summary, target.id);
final MessageChanges changes = MessageChanges(
mailboxId: target.id,
isJunk: isJunk,
);
state = state.copyWith(busy: MessageReaderBusy.removing);
try {
final MessageSummary summary = await repository.updateMessage(
messageId,
changes,
);
// 끝난 세션의 결과는 새 세션에 알리지 않는다 — [되돌리기]도 없다
if (tokens.sessionGeneration != session) return false;
watcher.markChanged(
updated: <MessageSummary>[_applyResponse(changes, summary)],
);
if (origin == null) {
notice(text);
} else {
toast.offer(
text,
actionLabel: MailBulkTexts.undo,
onAction: () => _restoreMessage(
messageId,
mailboxId: origin,
isJunk: isJunk == null ? null : !isJunk,
repository: repository,
watcher: watcher,
notice: notice,
tokens: tokens,
session: session,
),
);
}
if (ref.mounted) state = state.copyWith(clearBusy: true);
return true;
} on DioException {
if (ref.mounted) state = state.copyWith(clearBusy: true);
return false;
}
}
/// 되돌릴 메일함 — 옮길 곳이 아닌 첫 메일함
String? _originOf(MessageSummary summary, String targetId) {
for (final String id in summary.mailboxIds) {
if (id != targetId) return id;
}
return null;
}
/// 영구 삭제 — 확인 창에서 [영구 삭제]를 누른 뒤에만 부른다. 지웠으면 참(목록으로 돌아간다).
/// 서버가 그대로 두었으면(그사이 휴지통을 떠남) 알리고 메일 정보를 다시 받는다
Future<bool> purge() async {
final Ref ref = this.ref;
final MessageDetail? detail = state.detail;
if (!ref.mounted || detail == null || state.busy != null) return false;
final MailRepository repository = _repository;
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
final TokenStore tokens = ref.read(tokenStoreProvider);
final int session = tokens.sessionGeneration;
state = state.copyWith(busy: MessageReaderBusy.removing);
try {
final MessageDeletions result = await repository.deleteMessages(<String>[
messageId,
]);
if (tokens.sessionGeneration != session) return false;
final bool deleted = result.deletedIds.contains(messageId);
if (deleted) {
watcher.markChanged(removedIds: <String>{messageId});
notice(MailBulkTexts.purged(1));
} else {
watcher.markChanged();
notice(MailBulkTexts.purged(0, skipped: 1));
}
if (ref.mounted) {
state = state.copyWith(clearBusy: true);
if (!deleted) unawaited(_refreshSummary());
}
return deleted;
} on DioException {
if (ref.mounted) state = state.copyWith(clearBusy: true);
return false;
}
}
// ── 붙잡힌 메일 ─────────────────────────────────────
/// [보내기 취소] · [예약 취소] — 보내지 않고 임시보관함으로. 취소했으면 참(목록으로 돌아간다).
/// 이미 보냈으면(`MAIL_014` — 인터셉터가 알렸다) 메일 정보를 다시 받아 안내 줄을 맞춘다
Future<bool> cancelHeld() async {
final Ref ref = this.ref;
final PendingSend? pending = state.detail?.summary.pendingSend;
if (!ref.mounted || pending == null || state.busy != null) return false;
final MailRepository repository = _repository;
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
final TokenStore tokens = ref.read(tokenStoreProvider);
final int session = tokens.sessionGeneration;
state = state.copyWith(busy: MessageReaderBusy.cancelling);
try {
await repository.cancelPendingSend(messageId);
if (tokens.sessionGeneration != session) return false;
watcher.markChanged(removedIds: <String>{messageId});
notice(
pending.kind == PendingSendKind.scheduled
? MailReaderTexts.scheduleCanceled
: MailReaderTexts.undoSendDone,
);
if (ref.mounted) state = state.copyWith(clearBusy: true);
return true;
} on DioException {
if (!ref.mounted) return false;
state = state.copyWith(clearBusy: true);
unawaited(_refreshSummary());
return false;
}
}
/// 보내지 않은 메일의 [삭제] — 확인 창 뒤에만 부른다. 발송을 취소한 뒤 휴지통으로 옮긴다
/// (취소하지 않고 옮기면 엔진은 그 시각에 그대로 보낸다). 옮겼으면 참(목록으로 돌아간다)
Future<bool> deleteHeld() async {
final Ref ref = this.ref;
if (!ref.mounted || !state.isHeld || state.busy != null) return false;
final MailRepository repository = _repository;
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
final TokenStore tokens = ref.read(tokenStoreProvider);
final int session = tokens.sessionGeneration;
state = state.copyWith(busy: MessageReaderBusy.cancelling);
final String draftId;
try {
draftId = await repository.cancelPendingSend(messageId);
} on DioException {
if (!ref.mounted) return false;
state = state.copyWith(clearBusy: true);
unawaited(_refreshSummary());
return false;
}
if (tokens.sessionGeneration != session) return false;
// 발송은 취소했다 — 메일은 임시보관함에 있다(보낸편지함 목록에서 뺀다)
watcher.markChanged(removedIds: <String>{messageId});
final Mailbox? trash = await _mailboxOfRole(MailboxRole.trash);
if (tokens.sessionGeneration != session) return false;
if (trash != null) {
try {
final MessageSummary summary = await repository.updateMessage(
draftId,
MessageChanges(mailboxId: trash.id),
);
if (tokens.sessionGeneration != session) return false;
watcher.markChanged(updated: <MessageSummary>[summary]);
notice(MailReaderTexts.heldDeleted);
if (ref.mounted) state = state.copyWith(clearBusy: true);
return true;
} on DioException {
// 옮기지 못했다 — 알림은 인터셉터가 띄웠다. 메일은 임시보관함에 남는다
}
}
if (ref.mounted) {
state = state.copyWith(clearBusy: true);
unawaited(_refreshSummary());
}
return false;
}
/// 메일 정보만 다시 받는다(본문은 그대로) — 붙잡힌 발송이 풀렸는지 · 그사이 바뀐 메일함. 실패는 알리지 않는다
Future<void> _refreshSummary() async {
final Ref ref = this.ref;
final int seq = _seq;
try {
final ({MessageDetail detail, DateTime? serverTime}) result =
await _repository.getMessage(messageId);
if (!ref.mounted || seq != _seq) return;
_noteServerTime(result.serverTime);
_setSummary((MessageSummary _) => result.detail.summary);
} on DioException {
// 다음 차례에 다시 본다
}
if (ref.mounted && seq == _seq) _scheduleHeldCheck();
}
/// 붙잡힌 발송이 풀리는 때(보낼 시각 + 2초, 서버 시계)에 메일 정보를 다시 본다 — 그 뒤에도 붙잡힌 것으로 보이면 5초마다 3번 더
/// (엔진이 풀어도 새 메일 확인의 상태는 바뀌지 않는다 — 목록 컨트롤러와 같은 규칙)
void _scheduleHeldCheck() {
_cancelHeldTimer();
final PendingSend? pending = state.detail?.summary.pendingSend;
if (!ref.mounted || pending == null) return;
final DateTime now = _serverNow();
final DateTime due = pending.sendAt.add(kHeldReleaseGrace);
final Duration delay;
if (due.isAfter(now)) {
delay = due.difference(now);
} else {
if (_heldRechecks >= kHeldReleaseMaxRechecks) return;
delay = kHeldReleaseRecheck;
}
_heldTimer = Timer(delay, () {
_heldTimer = null;
if (!ref.mounted) return;
if (!due.isAfter(_serverNow())) _heldRechecks++;
unawaited(_refreshSummary());
});
}
void _cancelHeldTimer() {
_heldTimer?.cancel();
_heldTimer = null;
}
}
/// [되돌리기] — 옮긴 메일을 원래 메일함으로(스팸 표시는 반대로) 다시 옮긴 뒤 목록 · 메일함 개수를 맞춘다.
///
/// 알림이 읽기 화면보다 오래 남으므로(목록에서 누른다) 화면 없이 돈다. 만든 세션에서만 움직인다(메일 id 는 계정마다 겹칠 수 있다)
Future<void> _restoreMessage(
String messageId, {
required String mailboxId,
required bool? isJunk,
required MailRepository repository,
required MailStateWatcher watcher,
required void Function(String text) notice,
required TokenStore tokens,
required int session,
}) async {
if (tokens.sessionGeneration != session) return;
try {
final MessageSummary summary = await repository.updateMessage(
messageId,
MessageChanges(mailboxId: mailboxId, isJunk: isJunk),
);
if (tokens.sessionGeneration != session) return;
watcher.markChanged(updated: <MessageSummary>[summary]);
notice(MailBulkTexts.restored(1));
} on DioException catch (error) {
// 알림은 인터셉터가 띄웠다 — 그사이 지워졌으면(없음) 목록만 맞춘다
if (tokens.sessionGeneration != session) return;
if (apiErrorCodeOf(error) == 'RES_001') watcher.markChanged();
}
}
@@ -0,0 +1,82 @@
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:flutter/foundation.dart';
/// 읽기 화면의 상태
enum MessageReaderStatus {
/// 메일을 받는 중
loading,
/// 받았다
ready,
/// 받지 못했다 — 받지 못함 + [다시 시도] (없는 메일은 [목록으로])
failed,
}
/// 하는 중인 일 — 끝날 때까지 위 막대 · 안내 줄의 단추를 막는다
enum MessageReaderBusy {
/// 옮기기 · 스팸 · 휴지통 · 영구 삭제
removing,
/// 붙잡아 둔 발송 취소(보내기 취소 · 예약 취소 · 보내지 않은 메일의 삭제)
cancelling,
/// 읽음 표시 바꾸기([안 읽음 표시]는 끝나면 목록으로)
marking,
}
/// 메일 한 통의 읽기 화면
@immutable
class MessageReaderState {
const MessageReaderState({
this.status = MessageReaderStatus.loading,
this.detail,
this.error,
this.document,
this.bodyOmitted = false,
this.imagesLoading = false,
this.busy,
});
final MessageReaderStatus status;
final MessageDetail? detail;
/// 받지 못한 까닭 (코드 · 추적 ID)
final ApiException? error;
/// WebView 에 넣을 문서(정화 · CSP 를 마쳤다) — 만들기 전이면 null
final String? document;
/// 서식 본문이 앱의 정화 한도를 넘어 글자 본문을 보인다 — 서버의 생략(`isHtmlBodyOmitted`)과 같은 안내를 보인다
final bool bodyOmitted;
/// 본문 안내에 쓸 생략 표시 — 서버가 비웠거나 앱이 정화하지 않았다
bool get htmlOmitted => (detail?.isHtmlBodyOmitted ?? false) || bodyOmitted;
/// [이미지 보기]로 다시 받는 중 · 중계 그림을 받아 넣는 중
final bool imagesLoading;
/// 하는 중인 일 — 없으면 null
final MessageReaderBusy? busy;
/// 아직 보내지 않은 내 메일(보내기 취소를 기다림 · 예약) — 취소로만 보낸편지함을 떠난다
bool get isHeld => detail?.summary.isHeld ?? false;
MessageReaderState copyWith({
MessageDetail? detail,
String? document,
bool? bodyOmitted,
bool? imagesLoading,
MessageReaderBusy? busy,
bool clearBusy = false,
}) => MessageReaderState(
status: status,
detail: detail ?? this.detail,
error: error,
document: document ?? this.document,
bodyOmitted: bodyOmitted ?? this.bodyOmitted,
imagesLoading: imagesLoading ?? this.imagesLoading,
busy: clearBusy ? null : (busy ?? this.busy),
);
}
@@ -0,0 +1,135 @@
import 'dart:async';
import 'package:arasystem_app/features/mail/domain/mail_links.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
/// 메일 본문 — 앱이 만든 문서([document] — 두 번째 정화 · CSP 를 마쳤다 `mail_html.dart`)만 넣는 WebView 한 겹.
///
/// - 스크립트를 끈다. 문서 안의 CSP 가 모든 바깥 요청을 막는다(그림은 `data:` 만 — 외부 이미지는 앱이 받아 넣었다).
/// - 주 틀의 탐색은 모두 막는다 — 처음 넣은 문서(`about:blank`)만 열고, 누른 링크는 [onNavigation] 으로 앱에 넘긴다(`mail_links.dart`).
/// 아래 틀(iframe)의 탐색은 안드로이드가 이 판정에 넘기지 않는다(WebView 가 그대로 연다) — 아래 틀은 정화(iframe 을 내용째 버림)와
/// 문서의 CSP(`default-src 'none'` — frame-src)가 막는다.
/// - 안드로이드: 파일 · content 주소 접근 · 위치 · 권한 요청 · 파일 고르기를 끄고, 섞인 내용(http)을 막는다.
/// 글자 크기는 기기의 글자 크기 설정을 따른다(`setTextZoom` — WebView 는 저절로 따르지 않는다).
/// - 쿠키 · 캐시: 문서가 바깥에 나가지 않아 쿠키를 받거나 보낼 일이 없고 내려받아 둘 것도 없다 — 따로 지우지 않는다.
/// - 토큰은 WebView 에 가지 않는다(Bearer 는 Dio 만 — 그림 · 첨부는 앱이 받는다).
///
/// 높이: 머리(앱 화면) 아래 남은 자리를 다 쓰고 본문은 이 안에서 스크롤한다(앱 디자인 결정 — 스크립트가 꺼져 내용 높이를 잴 수 없다).
class MailBodyView extends StatefulWidget {
const MailBodyView({
required this.document,
required this.onNavigation,
super.key,
});
/// WebView 에 넣을 문서 — 바뀌면 다시 넣는다
final String document;
/// 누른 링크의 판정 — 바깥 브라우저 · 쓰기 화면 · 무시(앱이 연다)
final void Function(MailNavigation navigation) onNavigation;
@override
State<MailBodyView> createState() => _MailBodyViewState();
}
class _MailBodyViewState extends State<MailBodyView> {
late final WebViewController _controller = WebViewController();
late final Future<void> _configured = _configure();
/// 마지막으로 넣은 문서 — 같은 문서는 다시 넣지 않는다
String? _loaded;
int? _textZoom;
Color? _background;
@override
void initState() {
super.initState();
unawaited(_load(widget.document));
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final Color background = Theme.of(context).colorScheme.surface;
if (background != _background) {
_background = background;
unawaited(
_configured.then((_) => _controller.setBackgroundColor(background)),
);
}
final int zoom = MediaQuery.textScalerOf(context).scale(_percent).round();
if (zoom != _textZoom) {
_textZoom = zoom;
final AndroidWebViewController? android = _androidOf(_controller);
if (android != null) {
unawaited(_configured.then((_) => android.setTextZoom(zoom)));
}
}
}
/// 안드로이드 WebView 면 그 컨트롤러 — 시험의 가짜 WebView · iOS 는 null
static AndroidWebViewController? _androidOf(WebViewController controller) {
final Object platform = controller.platform;
return platform is AndroidWebViewController ? platform : null;
}
@override
void didUpdateWidget(MailBodyView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.document != _loaded) unawaited(_load(widget.document));
}
static const double _percent = 100;
Future<void> _configure() async {
final WebViewController controller = _controller;
await controller.setJavaScriptMode(JavaScriptMode.disabled);
await controller.setNavigationDelegate(
NavigationDelegate(onNavigationRequest: _onNavigationRequest),
);
final AndroidWebViewController? android = _androidOf(controller);
if (android != null) {
await android.setAllowFileAccess(false);
await android.setAllowContentAccess(false);
await android.setGeolocationEnabled(false);
await android.setMediaPlaybackRequiresUserGesture(true);
await android.setMixedContentMode(MixedContentMode.neverAllow);
await android.setOnShowFileSelector(
(FileSelectorParams _) async => const <String>[],
);
await android.setOnPlatformPermissionRequest(
(PlatformWebViewPermissionRequest request) => unawaited(request.deny()),
);
}
}
NavigationDecision _onNavigationRequest(NavigationRequest request) {
final MailNavigation navigation = mailNavigationOf(
request.url,
isMainFrame: request.isMainFrame,
);
if (navigation is OpenInitialDocument) return NavigationDecision.navigate;
if (mounted) widget.onNavigation(navigation);
return NavigationDecision.prevent;
}
Future<void> _load(String document) async {
_loaded = document;
await _configured;
// 기다리는 사이 더 새 문서가 왔으면 그것만 넣는다
if (!mounted || _loaded != document) return;
await _controller.loadHtmlString(document);
}
@override
Widget build(BuildContext context) {
return Semantics(
container: true,
label: MailReaderTexts.body,
child: WebViewWidget(controller: _controller),
);
}
}
@@ -59,12 +59,13 @@ class MailListEmpty extends StatelessWidget {
}
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일함은 [받은편지함으로] — 다시 해도 같다)
/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다)
/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다). 읽기 화면도 쓴다(없는 메일은 [목록으로])
class MailListError extends StatelessWidget {
const MailListError({
required this.error,
required this.onRetry,
this.onLeave,
this.leaveLabel = MailListTexts.toInbox,
super.key,
});
@@ -74,6 +75,9 @@ class MailListError extends StatelessWidget {
/// 없는 메일함(`RES_001`)일 때 갈 곳 — 없으면 늘 [다시 시도]
final VoidCallback? onLeave;
/// [onLeave] 단추의 이름
final String leaveLabel;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
@@ -94,7 +98,7 @@ class MailListError extends StatelessWidget {
),
TextButton(
onPressed: notFound ? leave : onRetry,
child: Text(notFound ? MailListTexts.toInbox : MailListTexts.retry),
child: Text(notFound ? leaveLabel : MailListTexts.retry),
),
],
);
@@ -0,0 +1,282 @@
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/core/utils/file_size.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
/// 첨부 목록 — "첨부 파일 N개" · 줄마다 파일 아이콘 · 이름 · 크기 · [받기] (보드 `App1Reader`).
///
/// 받는 동안은 크기 자리에 진행 막대 · "받는 중 64%" 이고 단추가 [받기 취소](✕)다.
/// 대용량 첨부(11단계)는 첫 판에서 받지 않는다 — 이름 · 크기와 "대용량 첨부"만 보이고, 목록 아래에 받는 길을 알린다.
class ReaderAttachments extends StatelessWidget {
const ReaderAttachments({
required this.attachments,
required this.largeFiles,
required this.downloads,
required this.onDownload,
required this.onCancel,
super.key,
});
/// 본문 안 그림을 뺀 첨부
final List<MessageAttachment> attachments;
final List<MessageLargeFile> largeFiles;
/// 받고 있는 첨부 — 첨부 id 마다
final Map<String, AttachmentDownload> downloads;
final ValueChanged<MessageAttachment> onDownload;
final ValueChanged<MessageAttachment> onCancel;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final int count = attachments.length + largeFiles.length;
return Container(
margin: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s16,
AppSpacing.s16,
AppSpacing.s12,
),
padding: const EdgeInsets.only(top: AppSpacing.s16),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: extra.borderSubtle,
width: AppSize.borderWidth,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s4),
child: Semantics(
header: true,
child: Text(
MailReaderTexts.attachmentsTitle(count),
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: AppTypography.semiBold,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
for (final MessageAttachment attachment in attachments) ...<Widget>[
const SizedBox(height: AppSpacing.s8),
_AttachmentRow(
key: ValueKey<String>('attachment-${attachment.id}'),
attachment: attachment,
download: downloads[attachment.id],
onDownload: () => onDownload(attachment),
onCancel: () => onCancel(attachment),
),
],
for (final MessageLargeFile file in largeFiles) ...<Widget>[
const SizedBox(height: AppSpacing.s8),
_FileRow(
icon: Icons.cloud_outlined,
name: file.name,
below: Text(
'${formatFileSize(file.size)} · ${MailReaderTexts.largeFile}',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: extra.textMuted,
),
),
),
],
if (largeFiles.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s4,
AppSpacing.s10,
AppSpacing.s4,
0,
),
child: KeepAllText(
MailReaderTexts.largeFilesNote,
style: theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
),
),
),
],
),
);
}
}
class _AttachmentRow extends StatelessWidget {
const _AttachmentRow({
required this.attachment,
required this.download,
required this.onDownload,
required this.onCancel,
super.key,
});
final MessageAttachment attachment;
/// 받는 중이면 그 진행 — 아니면 null
final AttachmentDownload? download;
final VoidCallback onDownload;
final VoidCallback onCancel;
static const List<FontFeature> _tabular = <FontFeature>[
FontFeature.tabularFigures(),
];
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final AttachmentDownload? progress = download;
final Widget below = progress == null
? Text(
formatFileSize(attachment.size),
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: extra.textMuted,
),
)
: Row(
children: <Widget>[
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.xs),
child: LinearProgressIndicator(
value: progress.fraction,
minHeight: AppSize.progressBarHeight,
backgroundColor: scheme.surfaceContainerHighest,
color: scheme.primary,
// 값은 진행 막대가 0 ~ 100 의 수로 읽힌다(Flutter 가 비율로 만든다)
semanticsLabel: MailReaderTexts.downloading(
attachment.name,
),
),
),
),
const SizedBox(width: AppSpacing.s8),
ExcludeSemantics(
child: Text(
MailReaderTexts.progress(progress.percent),
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: scheme.onPrimaryContainer,
fontFeatures: _tabular,
),
),
),
],
);
return _FileRow(
icon: Icons.insert_drive_file_outlined,
name: attachment.name,
below: below,
trailing: progress == null
? IconButton(
icon: const Icon(
Icons.download_outlined,
size: AppSize.iconMedium,
),
tooltip: MailReaderTexts.download(attachment.name),
color: extra.textStrong,
onPressed: onDownload,
)
: IconButton(
icon: const Icon(Icons.close, size: AppSize.iconMedium),
tooltip: MailReaderTexts.cancelDownload(attachment.name),
color: extra.textStrong,
onPressed: onCancel,
),
);
}
}
/// 파일 줄의 모양 — 테두리 상자 · 아이콘 칸(40) · 이름 · 아래 줄 · 오른쪽 단추
class _FileRow extends StatelessWidget {
const _FileRow({
required this.icon,
required this.name,
required this.below,
this.trailing,
});
final IconData icon;
final String name;
final Widget below;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final Widget? action = trailing;
return Container(
constraints: const BoxConstraints(
minHeight: AppSize.attachmentRowMinHeight,
),
padding: EdgeInsets.fromLTRB(
AppSpacing.s12,
AppSpacing.s8,
action == null ? AppSpacing.s12 : AppSpacing.s4,
AppSpacing.s8,
),
decoration: BoxDecoration(
border: Border.all(
color: scheme.outlineVariant,
width: AppSize.borderWidth,
),
borderRadius: BorderRadius.circular(AppRadius.xl),
),
child: Row(
children: <Widget>[
ExcludeSemantics(
child: Container(
width: AppSize.fileIconBox,
height: AppSize.fileIconBox,
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(
icon,
size: AppSize.iconMedium,
color: scheme.onSurfaceVariant,
),
),
),
const SizedBox(width: AppSpacing.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: AppTypography.medium,
),
),
const SizedBox(height: AppSpacing.s4),
below,
],
),
),
?action,
],
),
);
}
}
@@ -0,0 +1,253 @@
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:flutter/material.dart';
/// 더 보기 메뉴의 항목
enum ReaderMenuAction {
/// [안 읽음 표시] · [읽음 표시]
toggleRead,
/// [중요 표시] · [중요 해제]
toggleFlag,
/// [이동]
move,
/// [스팸 신고] · [스팸 아님]
toggleSpam,
}
/// 읽기 화면의 위 막대 — 뒤로 · (빈자리) · 삭제 · 더 보기 (보드 `App1Reader`, 높이 56)
class ReaderAppBar extends StatelessWidget implements PreferredSizeWidget {
const ReaderAppBar({
required this.onBack,
this.onDelete,
this.permanentDelete = false,
this.menu = const <ReaderMenuItem>[],
this.onMenu,
super.key,
});
final VoidCallback onBack;
/// [삭제] — null 이면 꺼진 단추(받는 중 · 하는 중)
final VoidCallback? onDelete;
/// 휴지통에만 든 메일 — [삭제]가 빨간 [영구 삭제]
final bool permanentDelete;
/// 더 보기 메뉴 — 비면 단추를 그리지 않는다
final List<ReaderMenuItem> menu;
/// 더 보기 메뉴에서 고른 것 — null 이면 꺼진 단추
final ValueChanged<ReaderMenuAction>? onMenu;
@override
Size get preferredSize => const Size.fromHeight(AppSize.readerBarHeight);
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final ValueChanged<ReaderMenuAction>? select = onMenu;
return AppBar(
toolbarHeight: AppSize.readerBarHeight,
automaticallyImplyLeading: false,
leadingWidth: AppSize.touchMin + AppSpacing.s4,
titleSpacing: 0,
leading: Padding(
padding: const EdgeInsets.only(left: AppSpacing.s4),
child: IconButton(
icon: const Icon(Icons.arrow_back, size: AppSize.iconLarge),
tooltip: MailReaderTexts.back,
color: extra.textStrong,
onPressed: onBack,
),
),
actionsPadding: const EdgeInsets.only(right: AppSpacing.s4),
actions: <Widget>[
IconButton(
icon: Icon(
permanentDelete
? Icons.delete_forever_outlined
: Icons.delete_outline,
size: AppSize.iconToolbar,
),
tooltip: permanentDelete
? MailReaderTexts.purge
: MailReaderTexts.delete,
color: permanentDelete ? scheme.error : extra.textStrong,
onPressed: onDelete,
),
if (menu.isNotEmpty)
PopupMenuButton<ReaderMenuAction>(
icon: const Icon(Icons.more_vert, size: AppSize.iconToolbar),
iconColor: extra.textStrong,
tooltip: MailReaderTexts.more,
enabled: select != null,
position: PopupMenuPosition.under,
constraints: const BoxConstraints.tightFor(
width: AppSize.readerMenuWidth,
),
color: scheme.surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
onSelected: select,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<ReaderMenuAction>>[
for (final ReaderMenuItem item in menu)
PopupMenuItem<ReaderMenuAction>(
value: item.action,
height: AppSize.touchMin,
child: Row(
children: <Widget>[
Icon(
item.icon,
size: AppSize.iconMedium,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: AppSpacing.s14),
Expanded(
child: Text(
item.label,
style: theme.textTheme.bodyMedium,
),
),
],
),
),
],
),
],
);
}
}
/// 더 보기 메뉴의 한 줄
@immutable
class ReaderMenuItem {
const ReaderMenuItem(this.action, this.label, this.icon);
final ReaderMenuAction action;
final String label;
final IconData icon;
}
/// 아래 행동 막대 — 답장 · 전체 답장 · 전달 (보드 `App1Reader`, 높이 64 · 위에 선, 화면 낭독기 이름 "답장과 전달").
/// 단추마다 null 이면 꺼진다(삭제 · 이동 등을 하는 동안 — 끝나면 목록으로 돌아가므로 그 위에 쓰기 화면을 열지 않게)
class ReaderActionBar extends StatelessWidget {
const ReaderActionBar({
required this.onReply,
required this.onReplyAll,
required this.onForward,
super.key,
});
final VoidCallback? onReply;
final VoidCallback? onReplyAll;
final VoidCallback? onForward;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Semantics(
container: true,
explicitChildNodes: true,
label: MailReaderTexts.actions,
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
top: BorderSide(
color: theme.colorScheme.outlineVariant,
width: AppSize.borderWidth,
),
),
),
child: SafeArea(
top: false,
child: SizedBox(
height: AppSize.readerActionBarHeight,
child: Row(
children: <Widget>[
_ActionButton(
icon: Icons.reply,
label: MailReaderTexts.reply,
color: extra.textStrong,
onPressed: onReply,
),
_ActionButton(
icon: Icons.reply_all,
label: MailReaderTexts.replyAll,
color: extra.textStrong,
onPressed: onReplyAll,
),
_ActionButton(
icon: Icons.forward,
label: MailReaderTexts.forward,
color: extra.textStrong,
onPressed: onForward,
),
],
),
),
),
),
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.icon,
required this.label,
required this.color,
required this.onPressed,
});
final IconData icon;
final String label;
final Color color;
/// null 이면 꺼진다
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final VoidCallback? press = onPressed;
final Color shown = press == null ? theme.disabledColor : color;
return Expanded(
child: Semantics(
button: true,
enabled: press != null,
label: label,
excludeSemantics: true,
onTap: press,
child: InkWell(
onTap: press,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(icon, size: AppSize.iconToolbar, color: shown),
const SizedBox(height: AppSpacing.s2),
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.semiBold,
color: shown,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,342 @@
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/message_reader_view.dart';
import 'package:flutter/material.dart';
/// 읽기 화면의 머리 — 제목 · 보낸 사람(동그란 사진 40) · 시각 · "받는 사람 나 외 1명"(누르면 펼침) (보드 `App1Reader`).
///
/// 펼치면 회색 상자에 보낸 사람 · 받는 사람 · 참조 · 숨은참조(내가 보낸 메일) · 날짜를 칸마다 이름 · 주소로 보인다.
class ReaderHeader extends StatefulWidget {
const ReaderHeader({
required this.detail,
required this.myEmail,
required this.now,
super.key,
});
final MessageDetail detail;
/// 지금 직원의 주소 — 받는 사람 가운데 있으면 "나"
final String? myEmail;
/// 시각 표기의 "오늘 · 어제"를 가르는 지금
final DateTime now;
@override
State<ReaderHeader> createState() => _ReaderHeaderState();
}
class _ReaderHeaderState extends State<ReaderHeader> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final MessageDetail detail = widget.detail;
final String subject = detail.summary.subject.isEmpty
? MailReaderTexts.noSubject
: detail.summary.subject;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s20,
AppSpacing.s6,
AppSpacing.s20,
0,
),
child: Semantics(
header: true,
child: Text(
subject,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: AppTypography.semiBold,
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s20,
AppSpacing.s14,
AppSpacing.s20,
0,
),
child: _SenderRow(
detail: detail,
myEmail: widget.myEmail,
now: widget.now,
expanded: _expanded,
onToggle: () => setState(() => _expanded = !_expanded),
),
),
if (_expanded) _ReaderDetails(detail: detail),
],
);
}
}
class _SenderRow extends StatelessWidget {
const _SenderRow({
required this.detail,
required this.myEmail,
required this.now,
required this.expanded,
required this.onToggle,
});
final MessageDetail detail;
final String? myEmail;
final DateTime now;
final bool expanded;
final VoidCallback onToggle;
static const List<FontFeature> _tabular = <FontFeature>[
FontFeature.tabularFigures(),
];
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final MailAddress? from = detail.summary.from;
final String sender = from?.displayName ?? MailReaderTexts.noSender;
final String? initial = from == null
? null
: initialOf(from.name, fallback: from.email);
final String recipients = recipientSummaryOf(detail, myEmail: myEmail);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
ExcludeSemantics(child: _Avatar(initial: initial)),
const SizedBox(width: AppSpacing.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
Expanded(
child: Text(
sender,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: AppTypography.semiBold,
color: scheme.onSurface,
),
),
),
const SizedBox(width: AppSpacing.s8),
Text(
formatReaderTime(detail.summary.receivedAt, now),
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: extra.textMuted,
fontFeatures: _tabular,
),
),
],
),
Semantics(
button: true,
expanded: expanded,
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.md),
onTap: onToggle,
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSize.touchMin,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Flexible(
child: Text(
recipients,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
const SizedBox(width: AppSpacing.s4),
Icon(
expanded ? Icons.expand_less : Icons.expand_more,
size: AppSize.iconTiny,
color: scheme.onSurfaceVariant,
),
],
),
),
),
),
],
),
),
],
);
}
}
class _Avatar extends StatelessWidget {
const _Avatar({required this.initial});
final String? initial;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final String? letter = initial;
return Container(
width: AppSize.avatar,
height: AppSize.avatar,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.primaryContainer,
),
child: letter == null
? Icon(
Icons.person_outline,
size: AppSize.iconMedium,
color: scheme.onPrimaryContainer,
)
: Text(
letter,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: AppTypography.semiBold,
color: scheme.onPrimaryContainer,
),
),
);
}
}
/// 머리 펼침 — 칸 이름(64) · 이름과 주소 (보드 `App1Reader` 「머리 펼침」)
class _ReaderDetails extends StatelessWidget {
const _ReaderDetails({required this.detail});
final MessageDetail detail;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final MailAddress? from = detail.summary.from;
final List<Widget> rows = <Widget>[
_DetailsRow(
label: MailReaderTexts.fromLabel,
lines: from == null
? const <ReaderAddressLine>[
(name: MailReaderTexts.noSender, email: null),
]
: <ReaderAddressLine>[readerAddressLine(from)],
),
if (detail.summary.to.isNotEmpty)
_DetailsRow(
label: MailReaderTexts.toLabel,
lines: detail.summary.to.map(readerAddressLine).toList(),
),
if (detail.cc.isNotEmpty)
_DetailsRow(
label: MailReaderTexts.ccLabel,
lines: detail.cc.map(readerAddressLine).toList(),
),
if (detail.bcc.isNotEmpty)
_DetailsRow(
label: MailReaderTexts.bccLabel,
lines: detail.bcc.map(readerAddressLine).toList(),
),
_DetailsRow(
label: MailReaderTexts.dateLabel,
lines: <ReaderAddressLine>[
(name: formatReaderFullDate(detail.summary.receivedAt), email: null),
],
),
];
return Container(
margin: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s10,
AppSpacing.s16,
AppSpacing.s4,
),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s14,
vertical: AppSpacing.s12,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
for (int index = 0; index < rows.length; index++) ...<Widget>[
if (index > 0) const SizedBox(height: AppSpacing.s8),
rows[index],
],
],
),
);
}
}
class _DetailsRow extends StatelessWidget {
const _DetailsRow({required this.label, required this.lines});
final String label;
final List<ReaderAddressLine> lines;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final TextStyle? labelStyle = theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
);
final TextStyle? nameStyle = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface,
);
final TextStyle? emailStyle = theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
);
return MergeSemantics(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: AppSize.detailsLabelWidth,
child: Text(label, style: labelStyle),
),
const SizedBox(width: AppSpacing.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
for (final ReaderAddressLine line in lines) ...<Widget>[
Text(line.name, style: nameStyle),
if (line.email case final String email)
Text(email, style: emailStyle),
],
],
),
),
],
),
);
}
}
@@ -0,0 +1,195 @@
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/message_reader_view.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
/// 머리 아래의 안내 줄 — 차례: 붙잡힌 메일(청록 — [보내기 취소] · [예약 취소]) → 본문 안내(생략 → 잘림) → 외부 이미지 차단(노랑 — [이미지 보기])
/// (보드 `App1Reader` · 웹 `MessageReaderNotices` 와 같은 차례)
class ReaderNotices extends StatelessWidget {
const ReaderNotices({
required this.detail,
required this.htmlOmitted,
required this.busy,
required this.imagesLoading,
required this.onShowImages,
required this.onCancelHeld,
super.key,
});
final MessageDetail detail;
/// 서식 본문을 보이지 않는다 — 서버가 비웠거나(`isHtmlBodyOmitted`) 앱의 정화 한도를 넘었다
final bool htmlOmitted;
/// 하는 일이 있다 — 안내 줄의 단추를 막는다
final bool busy;
/// [이미지 보기]로 다시 받는 중
final bool imagesLoading;
final VoidCallback onShowImages;
final VoidCallback onCancelHeld;
@override
Widget build(BuildContext context) {
final PendingSend? pending = detail.summary.pendingSend;
final List<MailBodyNotice> bodyNotices = mailBodyNoticesOf(
textBody: detail.textBody,
isHtmlBodyOmitted: htmlOmitted,
isBodyTruncated: detail.isBodyTruncated,
);
// 서식 본문을 보이지 않으면 외부 이미지도 없다 — [이미지 보기]를 두지 않는다
final bool imagesBlocked =
detail.hasBlockedImages && !detail.imagesShown && !htmlOmitted;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (pending != null)
ReaderNotice(
key: const ValueKey<String>('notice-held'),
tone: ReaderNoticeTone.brand,
icon: Icons.schedule,
text: pendingNoticeOf(pending),
actionLabel: pending.kind == PendingSendKind.scheduled
? MailReaderTexts.cancelSchedule
: MailReaderTexts.undoSend,
onAction: busy ? null : onCancelHeld,
),
for (final MailBodyNotice notice in bodyNotices)
ReaderNotice(
key: ValueKey<String>('notice-${notice.name}'),
tone: ReaderNoticeTone.warning,
icon: Icons.error_outline,
text: MailReaderTexts.bodyNotice(notice),
),
if (imagesBlocked)
ReaderNotice(
key: const ValueKey<String>('notice-images'),
tone: ReaderNoticeTone.warning,
icon: Icons.hide_image_outlined,
text: MailReaderTexts.imagesBlocked,
actionLabel: MailReaderTexts.showImages,
onAction: imagesLoading ? null : onShowImages,
),
],
);
}
}
/// 안내 줄의 색
enum ReaderNoticeTone {
/// 노랑 — 본문 안내 · 외부 이미지 차단
warning,
/// 청록 — 붙잡힌 메일
brand,
}
/// 안내 줄 하나 — 아이콘 · 글, 단추는 오른쪽 아래(누르는 곳 48 — 보드 `App1Reader` 의 [이미지 보기])
class ReaderNotice extends StatelessWidget {
const ReaderNotice({
required this.tone,
required this.icon,
required this.text,
this.actionLabel,
this.onAction,
super.key,
});
final ReaderNoticeTone tone;
final IconData icon;
final String text;
/// 단추 이름 — 없으면 단추 없는 줄
final String? actionLabel;
/// 단추 — null 이면 꺼진 단추(하는 중)
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final (Color background, Color border, Color foreground) = switch (tone) {
ReaderNoticeTone.warning => (
extra.warningSurface,
extra.warningBorder,
extra.warningText,
),
ReaderNoticeTone.brand => (
scheme.primaryContainer,
extra.brandTintBorder,
scheme.onPrimaryContainer,
),
};
final String? label = actionLabel;
return Container(
margin: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s12,
AppSpacing.s16,
0,
),
padding: EdgeInsets.fromLTRB(
AppSpacing.s14,
AppSpacing.s12,
label == null ? AppSpacing.s14 : AppSpacing.s6,
label == null ? AppSpacing.s12 : AppSpacing.s2,
),
decoration: BoxDecoration(
color: background,
border: Border.all(color: border, width: AppSize.borderWidth),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: AppSpacing.s2),
child: Icon(icon, size: AppSize.iconSmall, color: foreground),
),
const SizedBox(width: AppSpacing.s10),
Expanded(
child: KeepAllText(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: foreground,
height: AppTypography.lineHeightRelaxed,
),
),
),
],
),
if (label != null)
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: foreground,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s12,
),
textStyle: theme.textTheme.labelLarge?.copyWith(
fontWeight: AppTypography.bold,
),
),
onPressed: onAction,
child: Text(label),
),
),
],
),
);
}
}
+8
View File
@@ -1,7 +1,10 @@
import 'dart:async';
import 'package:arasystem_app/app.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/user_agent.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/core/storage/first_run_marker.dart';
import 'package:arasystem_app/core/storage/secure_store.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
@@ -38,6 +41,10 @@ Future<void> main() async {
final ({String? appVersion, String userAgent}) device =
await loadAppDeviceInfo();
// 지난 실행이 받아 둔 파일(메일 첨부)을 지운다 — 기다리지 않는다(저장소가 차례를 지켜 새로 받는 파일은 지우지 않는다)
final DownloadedFileStore downloadedFiles = CacheDirFileStore();
unawaited(downloadedFiles.purge());
runApp(
ProviderScope(
// 프로바이더의 실패를 저절로 다시 하지 않는다(core/utils/provider_retry.dart)
@@ -46,6 +53,7 @@ Future<void> main() async {
tokenStoreProvider.overrideWithValue(tokenStore),
userAgentProvider.overrideWithValue(device.userAgent),
appVersionProvider.overrideWithValue(device.appVersion),
downloadedFileStoreProvider.overrideWithValue(downloadedFiles),
],
child: const App(),
),