feat(flutter): 앱 첫 판 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 검색 · 새 메일 알림 #21

Merged
ttipo merged 34 commits from feat/app-push into main 2026-11-08 16:34:38 +09:00
51 changed files with 8325 additions and 48 deletions
Showing only changes of commit a37045a553 - Show all commits
+11 -2
View File
@@ -56,13 +56,22 @@ flutter build appbundle --release # Play 에 올리는 꼴 — 아래 서명
- **Android 백업을 껐다**(`android:allowBackup="false"` · `res/xml/data_extraction_rules.xml`) — 보안 저장소의 값은 이 기기의 Keystore 키로 암호화되어 다른 기기로 복원되면 읽지 못하고, 첫 실행 표시까지 복원되면 남은 토큰 지우기가 돌지 않는다.
- **iOS Keychain 은 앱을 지워도 남는다** — 일반 저장소(`shared_preferences`)의 첫 실행 표시가 없으면 앱을 띄우기 전에 보안 저장소를 모두 지운다(`core/storage/token_store.dart` 의 `prepare`).
- 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각)로 남은 시간을 잰다.
- **받은 메일 본문(WebView)**: 앱이 한 번 더 정화한 문서(`features/mail/domain/mail_html.dart` — 허용 목록 · CSP `default-src 'none'; img-src data:`)만 넣고,
스크립트 · 파일 · content 주소 접근을 끄고, 주 틀의 탐색은 모두 막는다(링크는 바깥 브라우저 · mailto 는 쓰기 화면). 아래 틀(iframe)의 탐색은 안드로이드가 판정에 넘기지 않는다 —
아래 틀은 정화(iframe 을 내용째 버림)와 CSP(`default-src 'none'` — frame-src)가 막는다. 외부 이미지는 앱이 Dio(Bearer)로 받아 `data:` 로 넣는다 — 토큰이 WebView 에 가지 않는다.
문서에 싣는 그림 글은 같은 그림이 여러 번 나오면 나온 만큼 세어 10MB 에서 멈춘다.
- **받은 첨부**: 앱 임시 폴더(`cacheDir`)의 `downloads/` 에 받고, 다른 앱으로 열 때는 FileProvider(`${applicationId}.downloads` — 그 폴더만)의 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다(`MainActivity.kt` 의 파일 채널).
앱을 띄울 때(지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때 모두 지운다. 파일 이름은 경로 · 제어 · 방향 제어 글자를 지우고 줄인다(`core/files/file_names.dart`).
열 형식은 정리한 이름의 확장자에서 먼저 정하고(보낸 쪽 형식은 확장자를 모를 때만), 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 받지 않고 알린다(`core/files/file_types.dart`).
고르는 창(`createChooser`)으로 열지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다.
## 나중에 넣을 때 주의
- **build_runner(freezed · json_serializable · riverpod_generator)**: 같은 폴더의 `lib/core/api/error_lexicon.g.dart`(백엔드 레지스트리에서 만든 사전 — 직접 고치지 않는다)를 build_runner 가 자기 출력으로 보고 지울 수 있다.
넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고, 처음 한 번 돌린 뒤 사전이 그대로인지(`git diff`) 확인한다.
- **첨부 받기(스트림 · 바이트 응답)**: `responseType` 이 `bytes` · `stream` 이면 실패 응답의 본문도 JSON 으로 풀리지 않아 `toApiException` 이 HTTP 상태로만 코드를 정한다(`FILE_007` · `FILE_008` 같은 코드를 잃는다).
받기를 만들 때 실패 본문(작다)을 끝까지 읽어 UTF-8 · JSON 으로 풀어 표준 실패의 `error.code` 를 꺼낸 뒤 코드를 정한다. 성공 본문은 지금처럼 풀지 않고 그대로 넘긴다(시험이 지킨다).
- **첨부 받기(스트림 · 바이트 응답)**: 실패 응답의 본문은 `ResponseInterceptor.onError` 가 64KB 까지 읽어 UTF-8 · JSON 으로 풀고 표준 실패의 `error.code` 를 꺼낸다
(`decodeRawErrorBody` — 그 전에는 HTTP 상태로만 정해 `FILE_007` · `FILE_008` 같은 코드를 잃었다). 성공 본문은 풀지 않고 그대로 넘긴다(시험이 지킨다).
받는 도중 끊기거나 조각 사이 받기 한도(`receiveTimeout` — Dio 가 스트림에 싣는다)를 넘기면 응답이 이미 인터셉터를 지나 아무도 알리지 않으므로 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`).
## 라이선스
@@ -35,6 +35,19 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!--
받은 첨부를 다른 앱으로 열 때 건네는 주소(content://) — 앱 임시 폴더의 downloads/ 안 파일만(res/xml/downloaded_file_paths.xml).
밖에 열지 않고(exported=false), 연 앱에 그 파일 하나의 읽기 권한만 준다(MainActivity.kt 의 파일 채널)
-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.downloads"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/downloaded_file_paths" />
</provider>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -1,5 +1,94 @@
package kr.it.arasystem.app
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.IOException
class MainActivity : FlutterActivity()
/**
* 앱 화면 하나 — Flutter 가 그린다.
*
* 파일 채널(`kr.it.arasystem.app/files`): 앱이 받은 파일(메일 첨부)을 다른 앱으로 연다 — Dart `core/files/downloaded_files.dart` 의
* `PlatformFileOpener` 가 부른다. 앱 임시 폴더의 [DOWNLOADS_DIR] 안 파일만, FileProvider 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다.
* 결과는 글 하나 — opened · noApp · notFound · failed. 경로 · 주소는 로그에 남기지 않는다.
*/
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FILES_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
OPEN_METHOD -> result.success(
openFile(call.argument<String>("path"), call.argument<String>("type")),
)
else -> result.notImplemented()
}
}
}
private fun openFile(path: String?, type: String?): String {
if (path == null) return RESULT_FAILED
val file: File
val root: File
try {
root = File(cacheDir, DOWNLOADS_DIR).canonicalFile
file = File(path).canonicalFile
} catch (error: IOException) {
return RESULT_FAILED
}
// 받은 파일 폴더 밖은 열지 않는다 (../ · 심볼릭 링크로 앱의 다른 파일을 건네지 않게)
if (!file.path.startsWith(root.path + File.separator)) return RESULT_FAILED
if (!file.isFile) return RESULT_NOT_FOUND
val uri = try {
FileProvider.getUriForFile(this, packageName + AUTHORITY_SUFFIX, file)
} catch (error: IllegalArgumentException) {
return RESULT_FAILED
}
// 고르는 창(createChooser)은 쓰지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, mimeTypeOf(type))
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
return try {
startActivity(intent)
RESULT_OPENED
} catch (error: ActivityNotFoundException) {
RESULT_NO_APP
} catch (error: SecurityException) {
RESULT_FAILED
}
}
/**
* 넘겨받은 형식 — Dart 가 정한다(`core/files/file_types.dart` 의 `fileOpenTypeOf` — 확장자에서 먼저, 열지 않는 형식은 받기 전에 막는다).
* 여기서는 모양만 다시 본다 — 틀리면 아무 형식(octet-stream)
*/
private fun mimeTypeOf(type: String?): String {
val given = type?.trim()?.lowercase()
return if (given != null && MIME_PATTERN.matches(given)) given else GENERIC_TYPE
}
private companion object {
/** 짝: Dart `PlatformFileOpener.channel` · `openMethod` */
const val FILES_CHANNEL = "kr.it.arasystem.app/files"
const val OPEN_METHOD = "open"
/** 짝: Dart `kDownloadedFilesFolder` · `res/xml/downloaded_file_paths.xml` 의 cache-path */
const val DOWNLOADS_DIR = "downloads"
/** 짝: `AndroidManifest.xml` 의 provider authorities(`${applicationId}.downloads`) */
const val AUTHORITY_SUFFIX = ".downloads"
/** 짝: Dart `kGenericFileType` · `_typePattern`(`core/files/file_types.dart`) */
const val GENERIC_TYPE = "application/octet-stream"
val MIME_PATTERN = Regex("^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$")
const val RESULT_OPENED = "opened"
const val RESULT_NO_APP = "noApp"
const val RESULT_NOT_FOUND = "notFound"
const val RESULT_FAILED = "failed"
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
앱이 다른 앱에 건네는 파일의 자리 — 앱 임시 폴더(cacheDir)의 downloads/ 한 곳뿐이다 (받은 메일 첨부).
짝: Dart core/files/downloaded_files.dart 의 kDownloadedFilesFolder · MainActivity.kt 의 DOWNLOADS_DIR
-->
<paths>
<cache-path
name="downloads"
path="downloads/" />
</paths>
+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(),
),
@@ -10,6 +10,7 @@ import flutter_secure_storage_darwin
import package_info_plus
import shared_preferences_foundation
import url_launcher_macos
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
@@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
}
+50 -2
View File
@@ -105,6 +105,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -301,6 +309,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
html:
dependency: "direct main"
description:
name: html
sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
url: "https://pub.dev"
source: hosted
version: "0.15.7"
http:
dependency: transitive
description:
@@ -422,7 +438,7 @@ packages:
source: hosted
version: "1.17.0"
mime:
dependency: transitive
dependency: "direct main"
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
@@ -486,7 +502,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -882,6 +898,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.1"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
url: "https://pub.dev"
source: hosted
version: "4.14.1"
webview_flutter_android:
dependency: "direct main"
description:
name: webview_flutter_android
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
url: "https://pub.dev"
source: hosted
version: "4.12.0"
webview_flutter_platform_interface:
dependency: "direct dev"
description:
name: webview_flutter_platform_interface
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
url: "https://pub.dev"
source: hosted
version: "3.25.1"
win32:
dependency: transitive
description:
+16
View File
@@ -29,20 +29,36 @@ dependencies:
flutter_secure_storage: ^10.0.0
# 화면 이동 — redirect 하나가 세션 상태로 화면을 고른다 (core/router/)
go_router: ^17.5.0
# 받은 메일 본문의 두 번째 정화 — HTML5 파서로 읽어 허용 목록만 남긴다 (features/mail/domain/mail_html.dart).
# 0.15.7 은 2026-08-28 배포(Dart 팀 tools.dart.dev). 따라오는 csslib 1.0.2 도 같은 곳
html: ^0.15.7
# 날짜 · 숫자 형식 — flutter_localizations 가 이 판으로 고정한다
intl: 0.20.2
# 받은 첨부를 열 형식 — 확장자 → MIME 표(core/files/file_types.dart). 이미 lock 에 따라 들어와 있던 2.0.0(2024-09-30 배포, Dart 팀) 그대로
mime: ^2.0.0
# 앱 판 — User-Agent 만들기
package_info_plus: ^10.2.2
# 받은 첨부를 둘 앱 임시 폴더(Android cacheDir) — 이미 shared_preferences 를 따라 들어와 있던 판이다 (core/files/)
path_provider: ^2.1.5
# 첫 실행 표시 하나만 둔다 — 토큰은 여기에 두지 않는다 (core/storage/first_run_marker.dart)
shared_preferences: ^2.5.6
# 다른 앱 열기 — 로그인 2단계 켜기의 [인증 앱에 추가](otpauth 주소 — features/auth/data/authenticator_launcher.dart).
# 6.3.3 은 2026-10-02 배포(Flutter 팀). canLaunchUrl 은 쓰지 않는다 — debug 판 안드로이드가 주소째 로그에 남긴다
url_launcher: ^6.3.3
# 받은 메일 본문 — 스크립트를 끈 WebView 한 겹(features/mail/presentation/widgets/mail_body_view.dart).
# 4.14.1 은 2026-07-07 배포(Flutter 팀). Dart 3.11 · Flutter 3.41 에 맞는 하위 판은
# webview_flutter_android 4.12.0(2026-04-29) · _wkwebview 3.25.1(2026-04-29) · _platform_interface 2.15.1(2026-03-25) 이다
# (그 위 판은 Dart 3.12 · Flutter 3.44 가 든다)
webview_flutter: ^4.14.1
# 안드로이드 WebView 의 파일 · content 접근 끄기 · 글자 크기 — 위 webview_flutter 가 고른 판 그대로
webview_flutter_android: ^4.12.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
# 시험의 가짜 WebView(test/support/fake_webview.dart) — webview_flutter 가 고른 판 그대로(새로 받는 것 없음)
webview_flutter_platform_interface: ^2.15.1
flutter:
uses-material-design: true
+8 -1
View File
@@ -13,6 +13,7 @@ import 'package:arasystem_app/features/auth/presentation/pages/login_page.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/pages/mail_list_page.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -181,7 +182,13 @@ void main() {
router.go(MailRoutes.message('m1'));
await tester.pumpAndSettle();
expect(page(MailTexts.message), findsOneWidget);
expect(find.byType(MessageReaderPage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
router.go(MailRoutes.compose);
await tester.pumpAndSettle();
expect(page(MailTexts.compose), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
});
@@ -0,0 +1,94 @@
import 'dart:io';
import 'dart:math';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// 받은 파일 보관(`CacheDirFileStore`) — 시험마다 만든 임시 폴더를 앱 임시 폴더로 쓴다(실제 파일 입출력)
void main() {
late Directory base;
late CacheDirFileStore store;
setUp(() async {
base = await Directory.systemTemp.createTemp('arasystem-files-test-');
store = CacheDirFileStore(
baseDirectory: () async => base,
random: Random(7),
);
});
tearDown(() async {
if (await base.exists()) await base.delete(recursive: true);
});
String rootPath() => '${base.path}/$kDownloadedFilesFolder';
test('받을 때마다 받은 파일 폴더 아래 새 폴더 한 칸 · 닫으면 조각이 모두 쓰인다', () async {
final DownloadTarget first = await store.create('견적서.pdf');
first
..add(<int>[1, 2, 3])
..add(<int>[4]);
await first.close();
final DownloadTarget second = await store.create('견적서.pdf');
await second.close();
expect(await File(first.path).readAsBytes(), <int>[1, 2, 3, 4]);
expect(second.path, isNot(first.path));
for (final DownloadTarget target in <DownloadTarget>[first, second]) {
expect(target.path, startsWith('${rootPath()}/'));
// 폴더 한 칸 + 이름 — 이름이 경로를 벗어나지 않는다
expect(target.path.substring(rootPath().length + 1).split('/'), <Object>[
hasLength(16),
'견적서.pdf',
]);
}
expect(await store.exists(first.path), isTrue);
});
test('보낸 쪽이 정한 이름의 경로 문자는 밑줄 — 받은 파일 폴더를 벗어나지 않는다', () async {
final DownloadTarget target = await store.create('../../etc/passwd');
await target.close();
expect(
target.path.substring(rootPath().length + 1).split('/'),
hasLength(2),
);
expect(target.path, endsWith('/_.._etc_passwd'));
});
test('받다 말면(discard) 쓰다 만 파일을 폴더째 지운다', () async {
final DownloadTarget target = await store.create('a.bin');
target.add(<int>[1, 2]);
await target.discard();
expect(await File(target.path).exists(), isFalse);
expect(await File(target.path).parent.exists(), isFalse);
});
test('purge — 받은 파일 폴더를 모두 지운다(없어도 된다)', () async {
final DownloadTarget target = await store.create('a.bin');
await target.close();
await store.purge();
expect(await store.exists(target.path), isFalse);
expect(await Directory(rootPath()).exists(), isFalse);
await store.purge();
});
test(
'임시 폴더를 모르면 자리 만들기는 FileSystemException(저장하지 못함), 정리는 조용히 넘어간다',
() async {
final CacheDirFileStore broken = CacheDirFileStore(
baseDirectory: () async => throw MissingPluginException('시험'),
);
await expectLater(
broken.create('a.bin'),
throwsA(isA<FileSystemException>()),
);
await broken.purge();
// 실패한 뒤에도 다음 일은 차례대로 돈다
await expectLater(
broken.create('b.bin'),
throwsA(isA<FileSystemException>()),
);
},
);
}
@@ -0,0 +1,81 @@
import 'package:arasystem_app/core/files/file_types.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mime/mime.dart';
/// 받은 파일을 열 형식 · 앱에서 열지 않는 형식 (`core/files/file_types.dart`)
void main() {
group('열 형식 — 확장자에서 먼저', () {
test('확장자를 알면 보낸 쪽 형식보다 확장자의 형식', () {
expect(fileOpenTypeOf('견적서.pdf', 'image/png'), 'application/pdf');
expect(fileOpenTypeOf('사진.JPG', 'text/plain'), 'image/jpeg');
expect(
fileOpenTypeOf('계약서.docx', 'application/octet-stream'),
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
);
});
test('확장자를 모르면 서버 형식 — 소문자로, 매개변수는 떼고', () {
expect(
fileOpenTypeOf('보고서.hwp', 'application/x-hwp'),
'application/x-hwp',
);
expect(fileOpenTypeOf('메모', 'Text/Plain; charset=UTF-8'), 'text/plain');
});
test('확장자도 서버 형식도 쓸 수 없으면 아무 형식(octet-stream)', () {
expect(fileOpenTypeOf('파일', null), kGenericFileType);
expect(fileOpenTypeOf('파일', ''), kGenericFileType);
expect(fileOpenTypeOf('파일.zzz', 'not a type'), kGenericFileType);
expect(fileOpenTypeOf('파일.zzz', 'text/plain<x>'), kGenericFileType);
});
test('확장자는 정리한 이름에서 본다 — 끝의 점 · 공백 · 제어 글자를 지운 뒤', () {
expect(fileExtensionOf('보고서.PDF. '), 'pdf');
expect(fileExtensionOf('a.htm\u0000'), 'htm');
expect(fileExtensionOf('.bashrc'), '');
expect(fileExtensionOf('이름'), '');
});
});
group('앱에서 열지 않는 형식', () {
test('확장자로 — 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음 (대소문자 · 끝 점과 상관없이)', () {
for (final String extension in kBlockedOpenExtensions) {
expect(
isBlockedFileOpen('파일.$extension', 'application/pdf'),
isTrue,
reason: extension,
);
}
expect(isBlockedFileOpen('안내.HTML', null), isTrue);
expect(isBlockedFileOpen('안내.html.', 'text/plain'), isTrue);
expect(isBlockedFileOpen('설치.Apk', null), isTrue);
});
test('확장자에서 나오는 형식은 모두 막는 형식에 든다(package:mime 의 표가 바뀌면 여기서 알린다)', () {
for (final String extension in kBlockedOpenExtensions) {
final String? type = lookupMimeType('file.$extension');
if (type == null) continue;
expect(kBlockedOpenTypes, contains(type), reason: extension);
}
});
test('서버 형식으로 — 확장자가 무해해도 보낸 쪽이 웹 문서 · 설치 파일이라고 하면', () {
expect(isBlockedFileOpen('보고서.pdf', 'text/html; charset=utf-8'), isTrue);
expect(isBlockedFileOpen('그림.png', 'IMAGE/SVG+XML'), isTrue);
expect(
isBlockedFileOpen('앱', 'application/vnd.android.package-archive'),
isTrue,
);
expect(isBlockedFileOpen('저장한 쪽', 'multipart/related'), isTrue);
});
test('흔한 첨부는 받는다 — 문서 · 그림 · 압축 · 전달된 메일', () {
expect(isBlockedFileOpen('견적서.pdf', 'application/pdf'), isFalse);
expect(isBlockedFileOpen('사진.jpg', 'image/jpeg'), isFalse);
expect(isBlockedFileOpen('자료.zip', 'application/zip'), isFalse);
expect(isBlockedFileOpen('보고서.hwp', 'application/x-hwp'), isFalse);
expect(isBlockedFileOpen('원본 메일.eml', 'message/rfc822'), isFalse);
expect(isBlockedFileOpen('이름 없음', null), isFalse);
});
});
}
@@ -0,0 +1,411 @@
import 'dart:typed_data';
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
import 'package:flutter_test/flutter_test.dart';
/// PNG 머리 — 그림 크기만 큰 시험 그림을 만든다
const List<int> kTestPng = <int>[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, //
];
/// 받은 메일 본문의 두 번째 정화 · WebView 문서 (웹 `mail-html.spec.ts` 의 결)
void main() {
String clean(String html, {bool imagesShown = false}) =>
sanitizeMailHtml(html, imagesShown: imagesShown).fragment;
group('정화 — 태그', () {
test('스크립트 · 스타일 · 틀 · 개체 · SVG · 수식은 내용째 버린다', () {
final String out = clean(
'<p>안녕</p><script>alert(1)</script><style>p{color:red}</style>'
'<iframe src="https://evil.example"></iframe><object data="x"></object>'
'<embed src="x"><svg><script>alert(2)</script><text>svg 글</text></svg>'
'<math><mi>x</mi></math><noscript><img src=x onerror=alert(3)></noscript>'
'<template><b>숨은 글</b></template>',
);
expect(out, '<p>안녕</p>');
});
test('폼 · 단추 · 모르는 태그는 태그만 지우고 글은 남긴다', () {
final String out = clean(
'<form action="https://evil.example"><button>보내기</button><o:p>본문</o:p></form>'
'<input value="숨은 값"><select><option>항목</option></select>',
);
expect(out, '보내기본문');
});
test('글 · 표 · 목록 · 인용은 남긴다', () {
final String out = clean(
'<blockquote><table><tbody><tr><td colspan="2">칸</td></tr></tbody></table>'
'<ul><li>하나</li></ul><br><hr></blockquote>',
);
expect(
out,
'<blockquote><table><tbody><tr><td colspan="2">칸</td></tr></tbody></table>'
'<ul><li>하나</li></ul><br><hr></blockquote>',
);
});
test('주석은 지우고 글은 이스케이프한다', () {
expect(
clean('<p><!-- 주석 -->a &lt;b&gt; &amp; c</p>'),
'<p>a &lt;b&gt; &amp; c</p>',
);
});
test('깊은 중첩도 스택이 넘치지 않고 끝난다(되풀이로 돈다)', () {
const int depth = 1500;
final String html = '${'<div>' * depth}끝${'</div>' * depth}';
final String out = clean(html);
expect(out, '${'<div>' * depth}끝${'</div>' * depth}');
});
test('맨 위 요소가 아주 많아도 문서로 읽어 고르게 끝난다', () {
final String html = '<span>a</span>' * 40000;
final String out = clean(html);
expect(out.length, html.length);
});
});
group('정화 전 비용 (중첩)', () {
test('태그마다 그때의 깊이를 더한다 — 빈 요소만 깊이를 늘리지 않는다', () {
expect(htmlNestingCost('<p>a</p>', limit: 1000), 0 + 1);
expect(
htmlNestingCost('<div><p>a</p></div>', limit: 1000),
0 + 1 + 2 + 1,
);
expect(
htmlNestingCost('<div><br><img src="x"><br/></div>', limit: 1000),
0 + 1 + 1 + 1 + 1,
);
expect(htmlNestingCost('글만 있다 &lt;div&gt;', limit: 1000), 0);
});
test('빈 요소가 아닌 <x/> 는 여는 태그로 센다 — 파서가 / 를 무시한다', () {
const int depth = 100;
expect(
htmlNestingCost('<div/>' * depth, limit: 1 << 30),
htmlNestingCost('<div>' * depth, limit: 1 << 30),
);
expect(
htmlNestingCost('<div><span/></div>', limit: 1000),
// span 이 열린 채라 </div> 는 깊이 2 에서 세고 닫지 않는다(맨 위가 span)
0 + 1 + 2,
);
});
test('주석을 건너뛰지 않는다 — 주석 안의 태그도 센다(서버가 주석을 지운다)', () {
expect(
htmlNestingCost('<!-- <div><div> --><p>a</p>', limit: 1000),
0 + 1 + 2 + 3,
);
expect(htmlNestingCost('<!--><div><div>', limit: 1000), 0 + 1);
expect(htmlNestingCost('<!-- x --!><div><div>', limit: 1000), 0 + 1);
const int depth = 300;
final String hidden = '<!--${'<div>' * depth}-->';
expect(
htmlNestingCost(hidden, limit: 1 << 30),
htmlNestingCost('<div>' * depth, limit: 1 << 30),
);
// 문서 형식 선언 · 엉뚱한 주석은 파서처럼 다음 > 까지 건너뛴다
expect(
htmlNestingCost('<!DOCTYPE html><?x?><div><div>', limit: 1000),
0 + 1,
);
});
test('맞지 않는 닫는 태그는 깊이를 줄이지 않는다 — 파서가 무시하는 </span> 으로 깊이를 덜 세지 않게', () {
const int n = 1000;
final String html = '${'<div>' * n}${'</span>' * n}${'<div>' * n}';
// 여는 div 2,000 개는 0 ~ 1,999 깊이에서, </span> 1,000 개는 깊이 1,000 에서 센다
const int expected = (2 * n - 1) * (2 * n) ~/ 2 + n * n;
expect(htmlNestingCost(html, limit: 1 << 30), expected);
// 맞는 닫는 태그는 줄인다 — 같은 깊이를 다시 연다
expect(
htmlNestingCost('<div></div><div></div>', limit: 1000),
0 + 1 + 0 + 1,
);
expect(
htmlNestingCost('<DIV><P>a</p></Div>', limit: 1000),
0 + 1 + 2 + 1,
);
});
test('한도를 넘으면 그 자리에서 멈춘다 — 깊은 본문은 정화하지 않고 글자 본문을 보인다', () {
const int depth = 20000;
final String deep = '${'<div>' * depth}끝${'</div>' * depth}';
expect(
htmlNestingCost(deep, limit: kMaxHtmlNestingCost),
greaterThan(kMaxHtmlNestingCost),
);
final SanitizedMailBody body = mailBodyFragment(
htmlBody: deep,
textBody: '글자 본문',
imagesShown: true,
);
expect(body.htmlOmitted, isTrue);
expect(body.fragment, '<div class="mail-text">글자 본문</div>');
expect(body.proxiedTokens, isEmpty);
});
test('서버 한도(2,000,000자)보다 긴 서식 본문도 글자 본문으로', () {
final SanitizedMailBody body = mailBodyFragment(
htmlBody: '<p>${'a' * kMaxSanitizedHtmlLength}</p>',
textBody: null,
imagesShown: false,
);
expect(body.htmlOmitted, isTrue);
expect(body.fragment, '<div class="mail-text"></div>');
});
});
group('정화 — 속성', () {
test('이벤트 처리기 · data-* · id · target · 외부 자원 속성은 지운다', () {
final String out = clean(
'<div id="x" onclick="alert(1)" data-ara-large-files="1" style="color:#123456" '
'background="https://t.example/b.png"><a href="https://ok.example" target="_blank" '
'rel="opener" ping="https://t.example">링크</a></div>',
);
expect(
out,
'<div style="color:#123456"><a href="https://ok.example">링크</a></div>',
);
});
test('링크는 http · https · mailto 만 남긴다', () {
expect(clean('<a href="javascript:alert(1)">a</a>'), '<a>a</a>');
expect(clean('<a href=" JavaScript:alert(1)">a</a>'), '<a>a</a>');
expect(clean('<a href="/api/messages">a</a>'), '<a>a</a>');
expect(clean('<a href="file:///etc/passwd">a</a>'), '<a>a</a>');
expect(clean('<a href="intent://x#Intent;end">a</a>'), '<a>a</a>');
expect(
clean('<a href="mailto:kim@example.com">a</a>'),
'<a href="mailto:kim@example.com">a</a>',
);
expect(
clean('<a href="HTTPS://Example.com/a?b=1&amp;c=2">a</a>'),
'<a href="HTTPS://Example.com/a?b=1&amp;c=2">a</a>',
);
});
test('속성 값의 따옴표 · 꺾쇠는 늘 이스케이프한다', () {
expect(
clean('<span title="a&quot;b>c<d" style="x">t</span>'),
'<span title="a&quot;b&gt;c&lt;d" style="x">t</span>',
);
});
test(
'그림은 data: 그림(PNG · JPEG · GIF · WebP)만 — SVG · 바깥 주소 · 상대 주소는 주소를 지운다',
() {
const String png = 'data:image/png;base64,iVBORw0KGgo=';
expect(clean('<img src="$png" alt="서명">'), '<img src="$png" alt="서명">');
expect(
clean('<img src="data:image/svg+xml;base64,PHN2Zz4=">'),
'<img>',
);
expect(
clean('<img src="https://t.example/p.gif" width="1">'),
'<img width="1">',
);
expect(clean('<img src="/api/mail-images/abc">'), '<img>');
expect(clean('<img srcset="https://t.example/a.png 2x">'), '<img>');
},
);
});
group('외부 이미지(중계 주소)', () {
test('외부 이미지를 보일 때만 중계 주소를 남기고 표를 문서 차례 · 겹치지 않게 모은다', () {
final SanitizedMailBody body = sanitizeMailHtml(
'<img src="/api/mail-images/tok_A" alt="a"><img src="/api/mail-images/tok-B">'
'<img src="/api/mail-images/tok_A"><img src="/api/mail-images/../x">'
'<img src="https://t.example/p.png">',
imagesShown: true,
);
expect(body.proxiedTokens, <String>['tok_A', 'tok-B']);
expect(
body.fragment,
'<img src="/api/mail-images/tok_A" alt="a"><img src="/api/mail-images/tok-B">'
'<img src="/api/mail-images/tok_A"><img><img>',
);
});
test('한 통에 100개까지만 받는다 — 넘는 그림은 주소를 지운다', () {
final String html = <String>[
for (int n = 0; n < kMaxProxiedImages + 5; n++)
'<img src="/api/mail-images/t$n">',
].join();
final SanitizedMailBody body = sanitizeMailHtml(html, imagesShown: true);
expect(body.proxiedTokens, hasLength(kMaxProxiedImages));
expect(body.fragment, endsWith('<img><img><img><img><img>'));
});
test('받은 그림으로 바꾸고, 받지 못한 그림은 주소를 지운다 — 글 속의 같은 모양에는 닿지 않는다', () {
final SanitizedMailBody body = sanitizeMailHtml(
'<p>&lt;img src="/api/mail-images/t1"</p>'
'<img src="/api/mail-images/t1" alt="x"><img src="/api/mail-images/t2">',
imagesShown: true,
);
final String resolved = resolveProxiedImages(
body.fragment,
<String, String>{'t1': 'data:image/png;base64,AAAA'},
);
expect(
resolved,
'<p>&lt;img src="/api/mail-images/t1"</p>'
'<img src="data:image/png;base64,AAAA" alt="x"><img>',
);
});
/// [token] 을 [count] 번 쓴 본문의 정화 조각
String repeated(String token, int count) => sanitizeMailHtml(
'<img src="/api/mail-images/$token">' * count,
imagesShown: true,
).fragment;
/// 실린 그림 수 · 지운 그림 수
({int shown, int hidden}) countImages(String resolved) => (
shown: '<img src="data:'.allMatches(resolved).length,
hidden: '<img>'.allMatches(resolved).length,
);
test(
'같은 그림이 여러 번 나오면 나온 만큼 센다 — 1MB 그림 1,000번도 문서가 한도 안이고 앞쪽 몇 개만 보인다',
() {
final Uint8List bytes = Uint8List(1024 * 1024)
..setRange(0, kTestPng.length, kTestPng);
final String data = imageDataUri('image/png', bytes);
final String fragment = repeated('big', 1000);
final String resolved = resolveProxiedImages(fragment, <String, String>{
'big': data,
});
final int fits = kMailImagesDocumentMaxLength ~/ data.length;
expect(fits, 7);
expect(countImages(resolved), (shown: fits, hidden: 1000 - fits));
expect(
resolved.length,
lessThanOrEqualTo(kMailImagesDocumentMaxLength + fragment.length),
);
// 앞쪽부터 싣는다
expect(resolved, startsWith('<img src="data:image/png;base64,'));
expect(resolved, endsWith('<img><img>'));
},
);
test('1KB 아이콘이 같은 표로 300번 나오면 모두 보인다', () {
final String icon = 'data:image/png;base64,${'A' * 1024}';
final String resolved = resolveProxiedImages(
repeated('icon', 300),
<String, String>{'icon': icon},
);
expect(countImages(resolved), (shown: 300, hidden: 0));
});
test(
'서로 다른 그림이 섞이면 문서 차례로 싣고, 넘는 자리부터는 뒤의 작은 그림도 지운다 — 받지 못한 그림은 세지 않는다',
() {
final SanitizedMailBody body = sanitizeMailHtml(
'<img src="/api/mail-images/a"><img src="/api/mail-images/b">'
'<img src="/api/mail-images/gone"><img src="/api/mail-images/a">'
'<img src="/api/mail-images/b"><img src="/api/mail-images/a">'
'<img src="/api/mail-images/b">',
imagesShown: true,
);
const String a = 'data:image/png;base64,AAAAAAAAAAAAAAAAAA'; // 40자
const String b = 'data:image/gif;base64,BB'; // 24자
// 셋째 a(40)가 넘친다 — 마지막 b(24)는 남은 자리(30)에 들지만 넘는 자리 뒤라 지운다
final String resolved = resolveProxiedImages(
body.fragment,
<String, String>{'a': a, 'b': b},
maxTotalLength: 40 + 24 + 40 + 24 + 30,
);
expect(
resolved,
'<img src="$a"><img src="$b"><img><img src="$a"><img src="$b"><img><img>',
);
},
);
});
group('정화할 곳 (그 자리 · 다른 isolate)', () {
MailBodyBuildPlace placeOf(String? html, [String? text]) =>
mailBodyBuildPlaceOf(htmlBody: html, textBody: text);
test('짧고 얕은 서식 본문 · 짧은 글자 본문만 그 자리에서', () {
expect(placeOf('<p>안녕하세요</p>'), MailBodyBuildPlace.inline);
expect(placeOf(null, '글자 본문'), MailBodyBuildPlace.inline);
expect(placeOf(' ', '공백뿐인 서식 본문은 글자 본문'), MailBodyBuildPlace.inline);
expect(
placeOf('<p>${'가' * (kMailBodyInlineMaxLength - 8)}</p>'),
MailBodyBuildPlace.inline,
);
});
test('길면 다른 isolate 에서 — 서식 본문 · 글자 본문 모두', () {
expect(
placeOf('<p>${'가' * kMailBodyInlineMaxLength}</p>'),
MailBodyBuildPlace.isolate,
);
expect(
placeOf(null, '가' * kMailBodyInlineMaxTextLength),
MailBodyBuildPlace.isolate,
);
});
test('짧아도 깊으면 다른 isolate 에서 — 2,800단 · 30KB 본문, 200단 · 2KB 본문', () {
final String deep = '${'<div>' * 2800}끝${'</div>' * 2800}';
expect(deep.length, lessThan(32 * 1024));
expect(placeOf(deep), MailBodyBuildPlace.isolate);
final String short = '${'<div>' * 200}끝${'</div>' * 200}';
expect(short.length, lessThan(kMailBodyInlineMaxLength));
expect(
htmlNestingCost(short, limit: 1 << 30),
greaterThan(kMailBodyInlineMaxCost),
);
expect(placeOf(short), MailBodyBuildPlace.isolate);
final String shallow = '${'<div>' * 40}끝${'</div>' * 40}';
expect(placeOf(shallow), MailBodyBuildPlace.inline);
});
});
group('본문 조각 · 문서', () {
test('HTML 이 없거나 공백뿐이면 글자 본문을 이스케이프해 보인다', () {
final SanitizedMailBody text = mailBodyFragment(
htmlBody: ' \n ',
textBody: '<b>굵게</b>\n둘째 줄',
imagesShown: false,
);
expect(
text.fragment,
'<div class="mail-text">&lt;b&gt;굵게&lt;/b&gt;\n둘째 줄</div>',
);
expect(displayedHtmlBody(null), isNull);
expect(displayedHtmlBody('<p>a</p>'), '<p>a</p>');
});
test('문서는 CSP 를 맨 앞에 두고 바깥 요청 · 스크립트를 막는다', () {
final String document = buildMailDocument('<p>본문</p>');
expect(
kMailDocumentCsp,
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
);
expect(
document,
startsWith(
'<!doctype html><html lang="ko"><head><meta charset="utf-8">'
'<meta http-equiv="Content-Security-Policy" content="$kMailDocumentCsp">',
),
);
expect(
document,
contains('<meta name="referrer" content="no-referrer">'),
);
expect(document, contains('<body><p>본문</p></body>'));
expect(document, isNot(contains('<script')));
});
});
}
@@ -9,6 +9,7 @@ import 'package:arasystem_app/features/mail/data/repositories/mail_repository.da
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/pages/mail_list_page.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.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';
@@ -76,10 +77,17 @@ void main() {
MailListPage currentPage(WidgetTester tester) =>
tester.widget<MailListPage>(find.byType(MailListPage));
/// 위에 쌓아 연 전체 화면(읽기 · 쓰기 자리)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
String pushedLocation(WidgetTester tester) => GoRouterState.of(
tester.element(find.byType(PlaceholderPage).last),
).uri.toString();
/// 위에 쌓아 연 전체 화면(읽기 화면 · 쓰기 자리)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
String pushedLocation(WidgetTester tester) {
final Finder reader = find.byType(MessageReaderPage);
return GoRouterState.of(
tester.element(
reader.evaluate().isNotEmpty
? reader.last
: find.byType(PlaceholderPage).last,
),
).uri.toString();
}
group('목록 줄', () {
testWidgets('받은편지함 — 보낸 사람 · 시각 · 제목 · 미리 보기, 안 읽음은 굵게 · 첨부 · 중요', (
@@ -122,7 +130,7 @@ void main() {
expect(tester.getSemantics(avatarOf('채용 일정')).label, '고르기: 채용 일정');
});
testWidgets('줄을 누르면 읽기 화면(탭 막대 없는 전체 화면) — 읽음 처리는 하지 않는다', (
testWidgets('줄을 누르면 읽기 화면(탭 막대 없는 전체 화면) — 목록은 읽음 처리를 하지 않는다(읽기 화면이 한다)', (
WidgetTester tester,
) async {
h.mail.seed(kInboxId, 2);
@@ -132,9 +140,16 @@ void main() {
await tester.pumpAndSettle();
expect(pushedLocation(tester), MailRoutes.message('m1'));
expect(find.text(MailTexts.message), findsOneWidget);
expect(find.byType(MessageReaderPage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
// 여러 통 바꾸기는 없다 — 읽음은 읽기 화면의 한 통 바꾸기
expect(h.mail.patches, isEmpty);
expect(
h.mail.singlePatches.map(
(({String id, Map<String, Object?> body}) p) => p.id,
),
<String>['m1'],
);
});
testWidgets('임시보관함 — 받는 사람을 보이고, 누르면 쓰기 화면에서 이어 쓴다', (
@@ -0,0 +1,506 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:arasystem_app/core/files/file_names.dart';
import 'package:arasystem_app/core/utils/file_size.dart';
import 'package:arasystem_app/features/mail/data/models/mail_address.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_body_notices.dart';
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
import 'package:arasystem_app/features/mail/domain/mail_links.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/message_reader_view.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/mail_fakes.dart';
/// 읽기 화면의 규칙 — 순수 함수 (링크 · mailto · 쓰기 화면 주소 · 파일 이름 · 크기 · 그림 판정 · 안내 · 머리 표기 · 메뉴)
void main() {
MessageDetail detailOf(
Map<String, Object?> overrides, {
String id = 'm1',
String mailboxId = kInboxId,
}) {
final MessageDetail? detail = MessageDetail.tryParse(<String, Object?>{
...messageJson(id, mailboxId: mailboxId),
'cc': <Object?>[],
'bcc': <Object?>[],
'htmlBody': null,
'textBody': '본문',
'hasBlockedImages': false,
'attachments': <Object?>[],
...overrides,
});
return detail!;
}
group('본문 링크 · 탐색 판정', () {
test('처음 넣은 문서(about:blank)만 연다 — 아래 틀은 열지 않는다', () {
expect(
mailNavigationOf('about:blank', isMainFrame: true),
isA<OpenInitialDocument>(),
);
expect(
mailNavigationOf('about:blank', isMainFrame: false),
isA<IgnoreNavigation>(),
);
expect(
mailNavigationOf('https://ok.example', isMainFrame: false),
isA<IgnoreNavigation>(),
);
});
test('http · https 는 바깥 앱으로, mailto 는 쓰기 화면으로, 그 밖은 무시', () {
final MailNavigation web = mailLinkOf('https://www.example.com/a?b=1');
expect(web, isA<OpenExternally>());
expect((web as OpenExternally).uri.host, 'www.example.com');
expect(mailLinkOf('HTTP://Example.com'), isA<OpenExternally>());
expect(mailLinkOf('mailto:kim@example.com'), isA<OpenCompose>());
for (final String url in <String>[
'javascript:alert(1)',
'file:///data/data/kr.it.arasystem.app/x',
'content://kr.it.arasystem.app.downloads/downloads/a',
'intent://scan/#Intent;scheme=zxing;end',
'tel:01012345678',
'about:srcdoc',
'/api/messages',
'https://',
'https://ok.example/\u0000x',
'',
'https://ok.example/${'a' * kMaxExternalLinkLength}',
]) {
expect(mailLinkOf(url), isA<IgnoreNavigation>(), reason: url);
}
});
test('mailto — 받는 사람 · 참조 · 숨은참조 · 제목 · 본문을 풀고, 틀린 주소 · 겹친 주소는 뺀다', () {
final MailNavigation link = mailLinkOf(
'mailto:kim@example.com,%20lee@example.com,bad,KIM@example.com'
'?cc=park@example.com&bcc=choi@example.com&subject=%ED%9A%8C%EC%9D%98%0A%EC%9D%BC%EC%A0%95'
'&body=%EC%95%88%EB%85%95%0A%ED%95%98%EC%84%B8%EC%9A%94&to=yoon@example.com',
);
final ComposePrefill prefill = (link as OpenCompose).prefill;
expect(prefill.to, <String>[
'kim@example.com',
'lee@example.com',
'yoon@example.com',
]);
expect(prefill.cc, <String>['park@example.com']);
expect(prefill.bcc, <String>['choi@example.com']);
expect(prefill.subject, '회의 일정');
expect(prefill.body, '안녕\n하세요');
});
test('mailto — 제목 · 본문은 한도에서 자른다', () {
final ComposePrefill prefill = composePrefillOfMailto(
Uri.parse(
'mailto:?subject=${'가' * 300}&body=${'나' * (kComposeMaxPrefillBody + 10)}',
),
)!;
expect(prefill.to, isEmpty);
expect(prefill.subject.length, kComposeMaxSubject);
expect(prefill.body.length, kComposeMaxPrefillBody);
expect(composePrefillOfMailto(Uri.parse('https://x.example')), isNull);
});
});
group('쓰기 화면 주소', () {
test('답장 · 전체 답장 · 전달 · 이어 쓰기는 웹과 같은 쿼리다', () {
expect(MailRoutes.composeReply('m 1'), '/mail/compose?replyTo=m+1');
expect(
MailRoutes.composeReply('m1', all: true),
'/mail/compose?replyTo=m1&all=1',
);
expect(MailRoutes.composeForward('m1'), '/mail/compose?forward=m1');
expect(MailRoutes.composeDraft('d1'), '/mail/compose?draft=d1');
});
test('쿼리 → 여는 까닭 (이어 쓰기 → 답장 → 전달 차례)', () {
expect(composeIntentOf(<String, String>{}), isA<NewComposeIntent>());
final ComposeIntent reply = composeIntentOf(
Uri.parse(MailRoutes.composeReply('m1', all: true)).queryParameters,
);
expect(reply, isA<ReplyComposeIntent>());
expect((reply as ReplyComposeIntent).messageId, 'm1');
expect(reply.all, isTrue);
expect(
composeIntentOf(<String, String>{'replyTo': 'm1'}),
isA<ReplyComposeIntent>().having(
(ReplyComposeIntent r) => r.all,
'all',
isFalse,
),
);
expect(
composeIntentOf(<String, String>{'forward': 'm2', 'draft': 'd1'}),
isA<DraftComposeIntent>(),
);
expect(
composeIntentOf(<String, String>{'forward': 'm2'}),
isA<ForwardComposeIntent>(),
);
});
});
group('받은 파일 이름', () {
test('경로 문자는 밑줄로, 제어 · 방향 제어 · 보이지 않는 글자는 지운다', () {
expect(safeFileName('../../etc/passwd'), '_.._etc_passwd');
expect(safeFileName(r'a\b:c*d?e"f<g>h|i.txt'), 'a_b_c_d_e_f_g_h_i.txt');
expect(safeFileName('보고\u0000서\u001F.pdf'), '보고서.pdf');
expect(safeFileName('invoice\u202Efdp.exe'), 'invoicefdp.exe');
expect(safeFileName('​견적.xlsx'), '견적.xlsx');
});
test('앞뒤 공백 · 점을 지우고, 비면 기본 이름', () {
expect(safeFileName(' .hidden.txt. '), 'hidden.txt');
expect(safeFileName('..'), kFallbackFileName);
expect(safeFileName(' \u0007 '), kFallbackFileName);
});
test('UTF-8 200바이트 안으로 줄이고 확장자는 남긴다', () {
final String name = safeFileName('${'가' * 120}.docx');
expect(utf8.encode(name).length, lessThanOrEqualTo(kMaxFileNameBytes));
expect(name, endsWith('.docx'));
expect(name, startsWith('가가가'));
final String noExtension = safeFileName('a' * 400);
expect(noExtension.length, kMaxFileNameBytes);
});
});
group('파일 크기 표기 (웹 formatFileSize 와 같다)', () {
test('B · KB(정수) · MB(10 미만은 소수 한 자리)', () {
expect(formatFileSize(0), '0 B');
expect(formatFileSize(1023), '1023 B');
expect(formatFileSize(248 * 1024), '248 KB');
expect(formatFileSize(1024 * 1024 - 1), '1 MB');
expect(formatFileSize((1.2 * 1024 * 1024).round()), '1.2 MB');
expect(formatFileSize(3 * 1024 * 1024), '3 MB');
expect(formatFileSize(38 * 1024 * 1024), '38 MB');
expect(formatFileSize(-1), '');
});
});
group('외부 이미지 판정', () {
test('앞 바이트로 PNG · JPEG · GIF · WebP 만 그림으로 본다', () {
expect(sniffImageType(Uint8List.fromList(kTinyPng)), 'image/png');
expect(
sniffImageType(Uint8List.fromList(<int>[0xFF, 0xD8, 0xFF, 0xE0])),
'image/jpeg',
);
expect(
sniffImageType(Uint8List.fromList(ascii.encode('GIF89a...'))),
'image/gif',
);
expect(
sniffImageType(
Uint8List.fromList(
ascii.encode('RIFF\u0000\u0000\u0000\u0000WEBPVP8 '),
),
),
'image/webp',
);
expect(
sniffImageType(
Uint8List.fromList(utf8.encode('<svg onload=alert(1)>')),
),
isNull,
);
expect(sniffImageType(Uint8List(0)), isNull);
});
test('그림 하나 5MB · 합계 10MB 를 넘으면 넣지 않는다', () {
final Uint8List png = Uint8List.fromList(kTinyPng);
expect(
acceptMailImage(png, usedBytes: 0),
startsWith('data:image/png;base64,'),
);
expect(
acceptMailImage(png, usedBytes: kMailImagesTotalMaxBytes - 1),
isNull,
);
final Uint8List big = Uint8List(kMailImageMaxBytes + 1)
..setAll(0, kTinyPng);
expect(acceptMailImage(big, usedBytes: 0), isNull);
});
});
group('본문 안내 (웹 mailBodyNotices 와 같다)', () {
test('생략 → 잘림 차례, 보여 줄 본문이 없으면 잘림은 뺀다', () {
expect(
mailBodyNoticesOf(
textBody: '글',
isHtmlBodyOmitted: true,
isBodyTruncated: true,
),
<MailBodyNotice>[MailBodyNotice.htmlOmitted, MailBodyNotice.truncated],
);
expect(
mailBodyNoticesOf(
textBody: ' ',
isHtmlBodyOmitted: true,
isBodyTruncated: true,
),
<MailBodyNotice>[MailBodyNotice.unavailable],
);
expect(
mailBodyNoticesOf(
textBody: null,
isHtmlBodyOmitted: false,
isBodyTruncated: true,
),
<MailBodyNotice>[MailBodyNotice.truncated],
);
expect(
mailBodyNoticesOf(
textBody: null,
isHtmlBodyOmitted: false,
isBodyTruncated: false,
),
isEmpty,
);
});
});
group('머리 표기', () {
final DateTime now = DateTime(2026, 11, 5, 15);
test('시각 — 오늘 · 어제 · 올해 · 그 전 (기기 시간대)', () {
expect(formatReaderTime(DateTime(2026, 11, 5, 10, 42), now), '오전 10:42');
expect(
formatReaderTime(DateTime(2026, 11, 4, 16, 20), now),
'어제 오후 4:20',
);
expect(
formatReaderTime(DateTime(2026, 3, 1, 9, 5), now),
'3월 1일 오전 9:05',
);
expect(
formatReaderTime(DateTime(2025, 10, 24, 12, 30), now),
'2025. 10. 24. 오후 12:30',
);
expect(
formatReaderFullDate(DateTime(2026, 11, 4, 16, 20)),
'2026년 11월 4일 (수) 오후 4:20',
);
});
test('받는 사람 줄 — 지금 직원이면 "나", 받는 사람 · 참조 · 숨은참조를 합쳐 센다', () {
final MessageDetail toMe = detailOf(<String, Object?>{
'to': <Object?>[addressJson('김아라', 'Ara.Kim@arasystem.it.kr')],
});
expect(
recipientSummaryOf(toMe, myEmail: 'ara.kim@arasystem.it.kr'),
'받는 사람 나',
);
final MessageDetail withCc = detailOf(<String, Object?>{
'to': <Object?>[addressJson('김아라', 'ara.kim@arasystem.it.kr')],
'cc': <Object?>[
addressJson('박준호', 'junho@arasystem.it.kr'),
addressJson('김아라', 'ara.kim@arasystem.it.kr'),
],
});
expect(
recipientSummaryOf(withCc, myEmail: 'ara.kim@arasystem.it.kr'),
'받는 사람 나 외 1명',
);
final MessageDetail others = detailOf(<String, Object?>{
'to': <Object?>[
addressJson('박준호', 'junho@arasystem.it.kr'),
<String, Object?>{'name': null, 'email': 'x@example.com'},
],
'bcc': <Object?>[addressJson('최', 'choi@example.com')],
});
expect(
recipientSummaryOf(others, myEmail: 'ara.kim@arasystem.it.kr'),
'받는 사람 박준호 외 2명',
);
expect(
recipientSummaryOf(detailOf(const <String, Object?>{}), myEmail: null),
MailReaderTexts.noRecipients,
);
});
test('펼친 칸의 줄 — 이름이 없거나 주소와 같으면 주소만', () {
expect(readerAddressLine(const MailAddress(email: 'a@x.kr', name: '김')), (
name: '김',
email: 'a@x.kr',
));
expect(readerAddressLine(const MailAddress(email: 'a@x.kr')), (
name: 'a@x.kr',
email: null,
));
expect(
readerAddressLine(const MailAddress(email: 'a@x.kr', name: 'a@x.kr')),
(name: 'a@x.kr', email: null),
);
});
test('붙잡힌 메일의 안내 — 예약은 시각, 보내기 취소를 기다리면 "곧 보낼 메일입니다."', () {
expect(
pendingNoticeOf(
PendingSend(
sendAt: DateTime(2026, 11, 7, 9),
kind: PendingSendKind.scheduled,
),
),
'11월 7일 (토) 오전 9:00 에 보낼 메일입니다.',
);
expect(
pendingNoticeOf(
PendingSend(
sendAt: DateTime(2026, 11, 7),
kind: PendingSendKind.undo,
),
),
MailReaderTexts.sendingNotice,
);
});
});
group('삭제 · 메뉴', () {
const Mailbox trash = Mailbox(
id: kTrashId,
name: '휴지통',
role: MailboxRole.trash,
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
);
test('휴지통에만 든 메일만 영구 삭제다 — 다른 메일함에도 있으면 휴지통으로 옮기기', () {
MessageSummary inBoxes(List<String> ids) =>
detailOf(<String, Object?>{'mailboxIds': ids}).summary;
expect(deletesPermanently(inBoxes(<String>[kTrashId]), trash), isTrue);
expect(
deletesPermanently(inBoxes(<String>[kTrashId, kInboxId]), trash),
isFalse,
);
expect(deletesPermanently(inBoxes(<String>[kInboxId]), trash), isFalse);
expect(deletesPermanently(inBoxes(<String>[kTrashId]), null), isFalse);
});
MailboxesState ready() => MailboxesState(
status: MailboxesStatus.ready,
items: <Mailbox>[
for (final Map<String, Object?> json in standardMailboxes())
Mailbox.tryParse(json)!,
],
);
test('메뉴 — 읽음 · 중요는 늘, 스팸메일함에서는 스팸 아님, 붙잡힌 메일은 이동 · 스팸이 없다', () {
final List<String> normal = <String>[
for (final ReaderMenuItem item in readerMenuOf(
detailOf(<String, Object?>{'isRead': true}),
ready(),
))
item.label,
];
expect(normal, <String>[
MailReaderTexts.markUnread,
MailReaderTexts.flag,
MailReaderTexts.move,
MailReaderTexts.reportSpam,
]);
final List<String> junk = <String>[
for (final ReaderMenuItem item in readerMenuOf(
detailOf(<String, Object?>{
'isFlagged': true,
'mailboxIds': <String>[kJunkId],
}),
ready(),
))
item.label,
];
expect(junk, <String>[
MailReaderTexts.markRead,
MailReaderTexts.unflag,
MailReaderTexts.move,
MailReaderTexts.notSpam,
]);
final List<String> held = <String>[
for (final ReaderMenuItem item in readerMenuOf(
detailOf(<String, Object?>{
'mailboxIds': <String>[kSentId],
'pendingSend': pendingJson(DateTime.utc(2026, 11, 7)),
}),
ready(),
))
item.label,
];
expect(held, <String>[MailReaderTexts.markRead, MailReaderTexts.flag]);
// 메일함 목록을 받기 전에는 옮길 곳 · 스팸메일함을 몰라 이동 · 스팸이 없다
expect(
readerMenuOf(
detailOf(const <String, Object?>{}),
const MailboxesState(),
),
hasLength(2),
);
});
});
group('메일 한 통 읽기 (MessageDetail)', () {
test('9단계 뒤 칸이 없으면 거짓 · 빈 목록으로 읽고, 본문 그림은 첨부 목록에서 뺀다', () {
final MessageDetail detail = detailOf(<String, Object?>{
'attachments': <Object?>[
attachmentJson('a1'),
attachmentJson(
'a2',
name: 'sig.png',
type: 'image/png',
isInline: true,
),
],
'largeFiles': <Object?>[
<String, Object?>{'fileId': 'f1', 'name': '큰파일.zip', 'size': 3},
<String, Object?>{'fileId': 1},
],
});
expect(detail.imagesShown, isFalse);
expect(detail.isBodyTruncated, isFalse);
expect(
detail.listedAttachments.map((MessageAttachment a) => a.id),
<String>['a1'],
);
expect(detail.largeFiles.single.name, '큰파일.zip');
});
test('모양이 틀리면 null — 첨부 · 본문 칸의 형식', () {
expect(detailOfOrNull(<String, Object?>{'htmlBody': 3}), isNull);
expect(
detailOfOrNull(<String, Object?>{
'attachments': <Object?>[
<String, Object?>{'id': 'a'},
],
}),
isNull,
);
expect(detailOfOrNull(<String, Object?>{'cc': 'x'}), isNull);
});
});
group('첨부 받기 진행', () {
test('비율 — 전체를 모르면 null', () {
expect(const AttachmentDownload(received: 64, total: 100).percent, 64);
expect(const AttachmentDownload(received: 64, total: 100).fraction, 0.64);
expect(const AttachmentDownload(received: 5).percent, isNull);
expect(const AttachmentDownload(received: 500, total: 100).percent, 100);
});
});
}
MessageDetail? detailOfOrNull(Map<String, Object?> overrides) =>
MessageDetail.tryParse(<String, Object?>{
...messageJson('m1', mailboxId: kInboxId),
'textBody': '본문',
...overrides,
});
@@ -1,12 +1,19 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
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:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
import '../../support/file_fakes.dart';
import '../../support/mail_fakes.dart';
/// 메일 저장소 — 계약 2절의 경로 · 본문 · 응답 읽기 (인터셉터 줄을 그대로 탄다)
@@ -202,4 +209,293 @@ void main() {
await errorOf(repository.mailState());
expect(h.notifier.shownCodes, <String>['MAIL_001']);
});
group('읽기 화면', () {
Map<String, Object?> detailJson({
Map<String, Object?> extra = const <String, Object?>{},
}) => <String, Object?>{
...messageJson('m1', mailboxId: kInboxId),
'cc': <Object?>[],
'bcc': <Object?>[],
'htmlBody': '<p>본문</p>',
'textBody': '본문',
'hasBlockedImages': true,
'imagesShown': false,
'attachments': <Object?>[attachmentJson('a1')],
...extra,
};
test('메일 한 통 — images=show 는 [이미지 보기]에만 싣고, 처음 받기의 실패는 알리지 않는다', () async {
h.server.on(
'GET',
MailApiPaths.message('m1'),
(_) => FakeResponse.ok(detailJson()),
);
final DateTime serverTime = DateTime.utc(2026, 11, 6, 9);
h.server.serverTime = serverTime;
final ({MessageDetail detail, DateTime? serverTime}) first =
await repository.getMessage('m1');
await repository.getMessage('m1', showImages: true, silent: false);
expect(first.detail.hasBlockedImages, isTrue);
expect(first.detail.attachments.single.id, 'a1');
expect(first.serverTime, serverTime);
expect(h.server.requests[0].queryParameters, isEmpty);
expect(h.server.requests[1].queryParameters, <String, Object?>{
kMailImagesQuery: kMailImagesShow,
});
h.server.on(
'GET',
MailApiPaths.message('m1'),
(_) => FakeResponse.fail(404, 'RES_001'),
);
expect(
apiErrorOf(await errorOf(repository.getMessage('m1'))).code,
'RES_001',
);
expect(h.notifier.shownCodes, isEmpty);
expect(
apiErrorOf(
await errorOf(
repository.getMessage('m1', showImages: true, silent: false),
),
).code,
'RES_001',
);
expect(h.notifier.shownCodes, <String>['RES_001']);
});
test(
'붙잡은 발송 취소 — DELETE /scheduled-messages/{id} 의 draftId, 이미 보냈으면 MAIL_014 를 알린다',
() async {
h.server.on(
'DELETE',
MailApiPaths.scheduledMessage('s/1'),
(_) => FakeResponse.ok(<String, Object?>{'draftId': 's/1'}),
);
expect(
MailApiPaths.scheduledMessage('s/1'),
'/scheduled-messages/s%2F1',
);
expect(await repository.cancelPendingSend('s/1'), 's/1');
h.server.on(
'DELETE',
MailApiPaths.scheduledMessage('s/1'),
(_) => FakeResponse.fail(409, 'MAIL_014'),
);
expect(
apiErrorOf(await errorOf(repository.cancelPendingSend('s/1'))).code,
'MAIL_014',
);
expect(h.notifier.shownCodes, <String>['MAIL_014']);
},
);
test('외부 이미지 — 바이트를 받고 실패는 알리지 않는다, 한도를 넘게 오면 끊는다', () async {
h.server.on(
'GET',
MailApiPaths.mailImage('tok'),
(_) => FakeResponse.bytes(kTinyPng, contentType: 'image/png'),
);
expect(await repository.fetchMailImage('tok', maxBytes: 1024), kTinyPng);
expect(bearerTokenOf(h.server.requests.last), 'access-1');
expect(
(await errorOf(repository.fetchMailImage('tok', maxBytes: 4))
as DioException)
.type,
DioExceptionType.cancel,
);
h.server.on(
'GET',
MailApiPaths.mailImage('tok'),
(_) => FakeResponse.fail(404, 'RES_001'),
);
expect(
apiErrorOf(
await errorOf(repository.fetchMailImage('tok', maxBytes: 1024)),
).code,
'RES_001',
);
expect(h.notifier.shownCodes, isEmpty);
});
test('첨부 받기 — 조각마다 이어 쓰고 진행을 알린다(길이 머리가 없으면 전체는 null)', () async {
final StreamController<Uint8List> chunks = StreamController<Uint8List>();
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a 1'),
(_) => FakeResponse.stream(chunks.stream, contentLength: 6),
);
final MemoryFileStore files = MemoryFileStore();
final DownloadTarget target = await files.create('견적서.pdf');
final List<(int, int?)> progress = <(int, int?)>[];
final Future<void> done = repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a 1',
target: target,
onProgress: (int received, int? total) =>
progress.add((received, total)),
);
await pumpEventsUntil(
() => h.server.requests.isNotEmpty,
reason: '요청이 서버에 닿기',
);
chunks.add(Uint8List.fromList(<int>[1, 2, 3]));
await pumpEventsUntil(() => progress.isNotEmpty, reason: '첫 조각');
chunks.add(Uint8List.fromList(<int>[4, 5, 6]));
await chunks.close();
await done;
await target.close();
expect(
MailApiPaths.attachment('m1', 'a 1'),
'/messages/m1/attachments/a%201',
);
expect(progress, <(int, int?)>[(3, 6), (6, 6)]);
expect(files.files[target.path], <int>[1, 2, 3, 4, 5, 6]);
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a 1'),
(_) => FakeResponse.stream(Stream<Uint8List>.value(Uint8List(2))),
);
final List<int?> totals = <int?>[];
await repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a 1',
target: await files.create('b'),
onProgress: (int _, int? total) => totals.add(total),
);
expect(totals, <int?>[null]);
});
test('첨부 받기의 실패 본문(스트림)도 코드로 읽는다 — 서버가 거절하면 인터셉터가 그 코드로 알린다', () async {
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a1'),
(_) => FakeResponse.fail(409, 'FILE_008'),
);
final MemoryFileStore files = MemoryFileStore();
final Object error = await errorOf(
repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a1',
target: await files.create('a'),
),
);
// 상태(409)로만 정하면 SYS_001 이다 — 본문의 코드를 읽었다
expect(apiErrorOf(error).code, 'FILE_008');
expect(apiErrorOf(error).traceId, 'trace-FILE_008');
expect(h.notifier.shownCodes, <String>['FILE_008']);
});
test(
'받는 도중 끊기거나 조각 사이 한도를 넘기면 SYS_001(DownloadInterrupted — 인터셉터는 알리지 않았다), 그만두면 취소',
() async {
final StreamController<Uint8List> broken =
StreamController<Uint8List>();
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a1'),
(_) => FakeResponse.stream(broken.stream, contentLength: 10),
);
final MemoryFileStore files = MemoryFileStore();
final Future<Object> failure = errorOf(
repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a1',
target: await files.create('a'),
),
);
await pumpEventsUntil(
() => h.server.requests.isNotEmpty,
reason: '요청이 서버에 닿기',
);
broken.add(Uint8List(3));
broken.addError(const SocketException('끊김(시험)'));
final Object error = await failure;
expect(error, isA<DownloadInterrupted>());
expect(apiErrorOf(error).code, 'SYS_001');
expect(h.notifier.shownCodes, isEmpty);
// 조각 사이 받기 한도(Dio 가 스트림에 싣는 receiveTimeout)도 끊긴 것이다 — 그만둔 것(cancel)만 그대로 둔다
final StreamController<Uint8List> stalled =
StreamController<Uint8List>();
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a1'),
(_) => FakeResponse.stream(stalled.stream, contentLength: 10),
);
final Future<Object> timedOut = errorOf(
repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a1',
target: await files.create('t'),
),
);
await pumpEventsUntil(
() => h.server.requests.length == 2,
reason: '두 번째 요청이 서버에 닿기',
);
stalled.add(Uint8List(1));
stalled.addError(
DioException.receiveTimeout(
timeout: const Duration(seconds: 15),
requestOptions: RequestOptions(
path: MailApiPaths.attachment('m1', 'a1'),
),
),
);
final Object timeout = await timedOut;
expect(timeout, isA<DownloadInterrupted>());
expect(apiErrorOf(timeout).code, 'SYS_001');
expect(h.notifier.shownCodes, isEmpty);
final StreamController<Uint8List> slow = StreamController<Uint8List>();
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a1'),
(_) => FakeResponse.stream(slow.stream, contentLength: 10),
);
final CancelToken cancel = CancelToken();
final Future<Object> cancelled = errorOf(
repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a1',
target: await files.create('b'),
cancelToken: cancel,
),
);
await pumpEventsUntil(
() => h.server.requests.length == 3,
reason: '세 번째 요청이 서버에 닿기',
);
slow.add(Uint8List(2));
await Future<void>.delayed(Duration.zero);
cancel.cancel();
expect(
((await cancelled) as DioException).type,
DioExceptionType.cancel,
);
await slow.close();
},
);
test('바이트 응답의 실패 본문도 코드로 읽는다(그림 · 파일)', () async {
h.server.on('GET', '/files/x', (_) => FakeResponse.fail(410, 'FILE_007'));
final Object error = await errorOf(
h.dio.get<List<int>>(
'/files/x',
options: Options(responseType: ResponseType.bytes),
),
);
expect(apiErrorOf(error).code, 'FILE_007');
});
});
}
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
/// 가짜 WebView — 시험 기기(Windows · 머리 없는 시험)에는 WebView 가 없다.
///
/// 읽기 화면이 실제로 하는 일(스크립트 끄기 · 탐색 판정 걸기 · 문서 넣기)을 적어 두고, 시험이 링크를 누른 것처럼 탐색을 물을 수 있다.
/// 안드로이드 전용 설정(`AndroidWebViewController` — 파일 · content 접근 끄기 등)은 이 가짜에서는 지나간다 — 실기기에서 본다.
class FakeWebViewPlatform extends WebViewPlatform {
/// 만든 컨트롤러 — 차례대로
final List<FakeWebViewController> controllers = <FakeWebViewController>[];
/// 마지막으로 만든 컨트롤러
FakeWebViewController get last => controllers.last;
@override
PlatformWebViewController createPlatformWebViewController(
PlatformWebViewControllerCreationParams params,
) {
final FakeWebViewController controller = FakeWebViewController(params);
controllers.add(controller);
return controller;
}
@override
PlatformNavigationDelegate createPlatformNavigationDelegate(
PlatformNavigationDelegateCreationParams params,
) => FakeNavigationDelegate(params);
@override
PlatformWebViewWidget createPlatformWebViewWidget(
PlatformWebViewWidgetCreationParams params,
) => FakeWebViewWidget(params);
}
/// 가짜 WebView 컨트롤러 — 받은 설정과 넣은 문서를 적는다
class FakeWebViewController extends PlatformWebViewController {
FakeWebViewController(super.params) : super.implementation();
JavaScriptMode? javaScriptMode;
Color? backgroundColor;
FakeNavigationDelegate? navigationDelegate;
/// 넣은 문서 — 차례대로
final List<String> loadedHtml = <String>[];
/// 넣은 문서의 기준 주소 — 차례대로(읽기 화면은 늘 null 이어야 한다)
final List<String?> baseUrls = <String?>[];
String? get lastHtml => loadedHtml.isEmpty ? null : loadedHtml.last;
@override
Future<void> setJavaScriptMode(JavaScriptMode javaScriptMode) async {
this.javaScriptMode = javaScriptMode;
}
@override
Future<void> setBackgroundColor(Color color) async {
backgroundColor = color;
}
@override
Future<void> setPlatformNavigationDelegate(
PlatformNavigationDelegate handler,
) async {
navigationDelegate = handler as FakeNavigationDelegate;
}
@override
Future<void> loadHtmlString(String html, {String? baseUrl}) async {
loadedHtml.add(html);
baseUrls.add(baseUrl);
}
/// 링크를 누른 것처럼 탐색을 묻는다 — 판정(열기 · 막기)을 돌려준다
Future<NavigationDecision> navigate(
String url, {
bool isMainFrame = true,
}) async {
final NavigationRequestCallback? callback =
navigationDelegate?.onNavigationRequest;
if (callback == null) return NavigationDecision.navigate;
return callback(NavigationRequest(url: url, isMainFrame: isMainFrame));
}
}
/// 가짜 탐색 판정 — 건 처리기를 쥔다
class FakeNavigationDelegate extends PlatformNavigationDelegate {
FakeNavigationDelegate(super.params) : super.implementation();
NavigationRequestCallback? onNavigationRequest;
@override
Future<void> setOnNavigationRequest(
NavigationRequestCallback onNavigationRequest,
) async {
this.onNavigationRequest = onNavigationRequest;
}
@override
Future<void> setOnPageStarted(PageEventCallback onPageStarted) async {}
@override
Future<void> setOnPageFinished(PageEventCallback onPageFinished) async {}
@override
Future<void> setOnProgress(ProgressCallback onProgress) async {}
@override
Future<void> setOnWebResourceError(
WebResourceErrorCallback onWebResourceError,
) async {}
@override
Future<void> setOnUrlChange(UrlChangeCallback onUrlChange) async {}
@override
Future<void> setOnHttpAuthRequest(
HttpAuthRequestCallback onHttpAuthRequest,
) async {}
@override
Future<void> setOnHttpError(HttpResponseErrorCallback onHttpError) async {}
}
/// 가짜 WebView 자리 — 아무것도 그리지 않는다
class FakeWebViewWidget extends PlatformWebViewWidget {
FakeWebViewWidget(super.params) : super.implementation();
@override
Widget build(BuildContext context) =>
const SizedBox.expand(key: ValueKey<String>('fake-webview'));
}
+85 -7
View File
@@ -141,7 +141,45 @@ class RecordingNotifier extends ErrorNotifier {
/// 가짜 응답
class FakeResponse {
const FakeResponse(this.status, this.body, {this.rawBody});
const FakeResponse(
this.status,
this.body, {
this.rawBody,
this.bytes,
this.stream,
this.headers,
});
/// 표준 래퍼 없는 바이트(그림 · 파일) — [contentType] 과 길이 머리를 싣는다
factory FakeResponse.bytes(
List<int> data, {
String contentType = 'application/octet-stream',
int status = 200,
}) => FakeResponse(
status,
null,
bytes: data,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[contentType],
Headers.contentLengthHeader: <String>['${data.length}'],
},
);
/// 조각으로 오는 파일 — 시험이 조각을 하나씩 흘린다. [contentLength] 가 없으면 길이 머리를 싣지 않는다
factory FakeResponse.stream(
Stream<Uint8List> chunks, {
int? contentLength,
String contentType = 'application/octet-stream',
}) => FakeResponse(
200,
null,
stream: chunks,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[contentType],
if (contentLength != null)
Headers.contentLengthHeader: <String>['$contentLength'],
},
);
/// 표준 성공 `{ success: true, data }`
factory FakeResponse.ok([Object? data]) =>
@@ -166,6 +204,15 @@ class FakeResponse {
/// JSON 이 아닌 그대로의 본문 (있으면 [body] 대신)
final String? rawBody;
/// 바이트 본문 (있으면 [body] · [rawBody] 대신)
final List<int>? bytes;
/// 조각으로 오는 본문 (있으면 다른 본문 대신)
final Stream<Uint8List>? stream;
/// 더 싣는 머리 — 같은 이름은 기본(JSON)을 덮는다
final Map<String, List<String>>? headers;
}
typedef FakeHandler = FutureOr<FakeResponse> Function(RequestOptions options);
@@ -180,10 +227,32 @@ class FakeServer implements HttpClientAdapter {
/// 응답의 `Date` 머리(서버 시각) — null 이면 싣지 않는다
DateTime? serverTime;
/// 경로 꼴마다 처리기 — 같은 경로의 [on] 이 없을 때 먼저 맞는 것
final List<({String method, RegExp path, FakeHandler handler})> _patterns =
<({String method, RegExp path, FakeHandler handler})>[];
void on(String method, String path, FakeHandler handler) {
_routes['$method $path'] = handler;
}
/// 경로 꼴(`^/messages/([^/]+)$` 등)에 처리기를 건다 — 처리기는 [RequestOptions.path] 로 칸을 꺼낸다
void onPattern(String method, RegExp path, FakeHandler handler) {
_patterns.add((method: method, path: path, handler: handler));
}
FakeHandler? _handlerOf(RequestOptions options) {
final FakeHandler? exact = _routes['${options.method} ${options.path}'];
if (exact != null) return exact;
for (final ({String method, RegExp path, FakeHandler handler}) pattern
in _patterns) {
if (pattern.method == options.method &&
pattern.path.hasMatch(options.path)) {
return pattern.handler;
}
}
return null;
}
/// [method] [path] 로 받은 요청들
List<RequestOptions> received(String method, String path) => requests
.where((RequestOptions r) => r.method == method && r.path == path)
@@ -197,7 +266,7 @@ class FakeServer implements HttpClientAdapter {
) async {
// 받은 그때의 모습을 남긴다 — 인터셉터가 실패를 내보내며 토큰 · 비밀 본문을 지워도 시험이 볼 수 있게
requests.add(options.copyWith());
final FakeHandler? handler = _routes['${options.method} ${options.path}'];
final FakeHandler? handler = _handlerOf(options);
final FakeResponse response = handler == null
? FakeResponse.fail(404, 'RES_001')
: await handler(options);
@@ -208,14 +277,23 @@ class FakeServer implements HttpClientAdapter {
);
}
final DateTime? date = serverTime;
final Map<String, List<String>> headers = <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
if (date != null) HttpHeaders.dateHeader: <String>[HttpDate.format(date)],
...?response.headers,
};
final Stream<Uint8List>? stream = response.stream;
if (stream != null) {
return ResponseBody(stream, response.status, headers: headers);
}
final List<int>? bytes = response.bytes;
if (bytes != null) {
return ResponseBody.fromBytes(bytes, response.status, headers: headers);
}
return ResponseBody.fromString(
response.rawBody ?? jsonEncode(response.body),
response.status,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
if (date != null)
HttpHeaders.dateHeader: <String>[HttpDate.format(date)],
},
headers: headers,
);
}
+96
View File
@@ -0,0 +1,96 @@
import 'dart:async';
import 'dart:io';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/core/files/file_names.dart';
/// 메모리 파일 보관 — 시험에서는 기기 파일 시스템을 쓰지 않는다(시험 시계 안에서 파일 입출력이 끝나지 않는다)
class MemoryFileStore implements DownloadedFileStore {
/// 다 받은 파일 — 경로마다 바이트
final Map<String, List<int>> files = <String, List<int>>{};
/// 받다 말아 지운 자리
final List<String> discarded = <String>[];
/// 받는 중인 자리
final Map<String, List<int>> writing = <String, List<int>>{};
int purgeCount = 0;
int _created = 0;
/// 켜면 자리 만들기가 실패한다(저장 공간 없음)
bool failCreate = false;
/// 있으면 받다 만 자리 지우기가 이것이 끝날 때까지 기다린다 — 앞 받기의 끝을 늦춘다
Completer<void>? discardGate;
@override
Future<DownloadTarget> create(String fileName) async {
if (failCreate) throw const FileSystemException('저장 공간 없음(시험)');
final String path =
'/cache/downloads/${++_created}/${safeFileName(fileName)}';
writing[path] = <int>[];
return _MemoryTarget(this, path);
}
@override
Future<bool> exists(String path) async => files.containsKey(path);
@override
Future<void> purge() async {
purgeCount++;
files.clear();
}
}
class _MemoryTarget implements DownloadTarget {
_MemoryTarget(this._store, this.path);
final MemoryFileStore _store;
@override
final String path;
@override
void add(List<int> chunk) => _store.writing[path]?.addAll(chunk);
@override
Future<void> close() async {
final List<int>? bytes = _store.writing.remove(path);
if (bytes != null) _store.files[path] = bytes;
}
@override
Future<void> discard() async {
await _store.discardGate?.future;
_store.writing.remove(path);
_store.files.remove(path);
_store.discarded.add(path);
}
}
/// 다른 앱으로 연 것을 적는 가짜 — [result] 로 결과를 정한다
class RecordingFileOpener implements FileOpener {
FileOpenResult result = FileOpenResult.opened;
final List<({String path, String mimeType})> opened =
<({String path, String mimeType})>[];
@override
Future<FileOpenResult> open({
required String path,
required String mimeType,
}) async {
opened.add((path: path, mimeType: mimeType));
return result;
}
}
/// 바깥 앱으로 연 링크를 적는 가짜 — [result] 로 열었는지 정한다
class RecordingLinkLauncher {
bool result = true;
final List<Uri> launched = <Uri>[];
Future<bool> call(Uri uri) async {
launched.add(uri);
return result;
}
}
+181
View File
@@ -75,6 +75,27 @@ Map<String, Object?> addressJson(String name, String email) =>
Map<String, Object?> pendingJson(DateTime sendAt, {String kind = 'undo'}) =>
<String, Object?>{'sendAt': sendAt.toUtc().toIso8601String(), 'kind': kind};
/// 메일의 첨부 하나 (계약 2절 `MessageAttachment`)
Map<String, Object?> attachmentJson(
String id, {
String name = '견적서.pdf',
String type = 'application/pdf',
int size = 248 * 1024,
bool isInline = false,
}) => <String, Object?>{
'id': id,
'name': name,
'type': type,
'size': size,
'isInline': isInline,
};
/// 1×1 PNG (외부 이미지 중계의 그림)
final List<int> kTinyPng = <int>[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, //
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
];
/// 기본 메일함 다섯과 내 메일함 셋(프로젝트 아래에 하나)
List<Map<String, Object?>> standardMailboxes({
int inboxUnread = 3,
@@ -131,6 +152,48 @@ class FakeMailBackend {
/// 받은 POST /message-deletions 의 ids
final List<List<String>> deletions = <List<String>>[];
// ── 읽기 화면 (메일 한 통 · 붙잡힌 발송 · 첨부 · 외부 이미지) ──
/// 메일 한 통의 읽기 칸 — 메일 id 마다(없으면 텍스트 본문 하나). `messageJson` 위에 덮는다
final Map<String, Map<String, Object?>> details =
<String, Map<String, Object?>>{};
/// `images=show` 로 물었을 때 덮는 칸 — 메일 id 마다(외부 이미지를 중계 주소로 바꾼 본문)
final Map<String, Map<String, Object?>> shownImageDetails =
<String, Map<String, Object?>>{};
/// 받은 GET /messages/{id} 의 쿼리 (메일 id · images)
final List<({String id, Object? images})> detailRequests =
<({String id, Object? images})>[];
/// 받은 PATCH /messages/{id} (메일 id · 본문)
final List<({String id, Map<String, Object?> body})> singlePatches =
<({String id, Map<String, Object?> body})>[];
/// 받은 DELETE /scheduled-messages/{id} 의 메일 id
final List<String> cancellations = <String>[];
FakeResponse? detailFailure;
FakeResponse? singlePatchFailure;
FakeResponse? cancelFailure;
/// 있으면 읽기 칸 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다)
Completer<void>? detailGate;
/// 있으면 한 통 바꾸기 응답이 이것이 끝날 때까지 기다린다
Completer<void>? singlePatchGate;
/// 첨부 받기의 응답 — 첨부 id 마다(없으면 404)
final Map<String, FakeResponse Function()> attachmentFiles =
<String, FakeResponse Function()>{};
/// 외부 이미지 중계의 응답 — 표마다(없으면 404)
final Map<String, FakeResponse> mailImages = <String, FakeResponse>{};
List<RequestOptions> get imageRequests => server.requests
.where((RequestOptions r) => r.path.startsWith('/mail-images/'))
.toList();
int get stateRequests =>
server.received('GET', MailApiPaths.mailState).length;
@@ -238,8 +301,126 @@ class FakeMailBackend {
if (failure != null) return failure;
return FakeResponse.ok(<String, Object?>{'state': state});
});
_installReader();
}
static final RegExp _messagePath = RegExp(r'^/messages/([^/]+)$');
static final RegExp _attachmentPath = RegExp(
r'^/messages/([^/]+)/attachments/([^/]+)$',
);
static final RegExp _scheduledPath = RegExp(r'^/scheduled-messages/([^/]+)$');
static final RegExp _imagePath = RegExp(r'^/mail-images/([^/]+)$');
String _idOf(RegExp pattern, String path, [int group = 1]) =>
Uri.decodeComponent(pattern.firstMatch(path)!.group(group)!);
void _installReader() {
server.onPattern('GET', _messagePath, (RequestOptions options) async {
final String id = _idOf(_messagePath, options.path);
final Object? images = options.queryParameters[kMailImagesQuery];
detailRequests.add((id: id, images: images));
final Completer<void>? gate = detailGate;
if (gate != null) {
detailGate = null;
await gate.future;
}
final FakeResponse? failure = detailFailure;
if (failure != null) return failure;
final Map<String, Object?>? detail = detailJsonOf(
id,
showImages: images == kMailImagesShow,
);
return detail == null
? FakeResponse.fail(404, 'RES_001')
: FakeResponse.ok(detail);
});
server.onPattern('PATCH', _messagePath, (RequestOptions options) async {
final String id = _idOf(_messagePath, options.path);
final Map<String, Object?> body = options.data! as Map<String, Object?>;
singlePatches.add((id: id, body: body));
await singlePatchGate?.future;
final FakeResponse? failure = singlePatchFailure;
if (failure != null) return failure;
final ({String mailboxId, Map<String, Object?> message})? found = _find(
id,
);
if (found == null) return FakeResponse.fail(404, 'RES_001');
final bool moves =
body.containsKey('mailboxId') || body.containsKey('isJunk');
if (moves && found.message['pendingSend'] != null) {
return FakeResponse.fail(409, 'MAIL_015');
}
_apply(id, body);
return FakeResponse.ok(Map<String, Object?>.of(_find(id)!.message));
});
server.onPattern('DELETE', _scheduledPath, (RequestOptions options) async {
final String id = _idOf(_scheduledPath, options.path);
cancellations.add(id);
final FakeResponse? failure = cancelFailure;
if (failure != null) return failure;
final ({String mailboxId, Map<String, Object?> message})? found = _find(
id,
);
if (found == null) return FakeResponse.fail(404, 'RES_001');
if (found.message['pendingSend'] == null) {
return FakeResponse.fail(409, 'MAIL_014');
}
found.message['pendingSend'] = null;
_apply(id, <String, Object?>{'mailboxId': kDraftsId});
return FakeResponse.ok(<String, Object?>{'draftId': id});
});
server.onPattern('GET', _attachmentPath, (RequestOptions options) {
final String attachmentId = _idOf(_attachmentPath, options.path, 2);
final FakeResponse Function()? file = attachmentFiles[attachmentId];
return file == null ? FakeResponse.fail(404, 'RES_001') : file();
});
server.onPattern('GET', _imagePath, (RequestOptions options) {
final String token = _idOf(_imagePath, options.path);
return mailImages[token] ?? FakeResponse.fail(404, 'RES_001');
});
}
/// 메일 한 통의 읽기 칸 — 목록 칸 위에 [details] (와 외부 이미지를 보일 때 [shownImageDetails])를 덮는다. 없는 메일은 null
Map<String, Object?>? detailJsonOf(String id, {bool showImages = false}) {
final ({String mailboxId, Map<String, Object?> message})? found = _find(id);
if (found == null) return null;
return <String, Object?>{
...found.message,
'cc': <Object?>[],
'bcc': <Object?>[],
'replyTo': <Object?>[],
'sentAt': found.message['receivedAt'],
'messageId': '<$id@example.com>',
'references': <Object?>[],
'htmlBody': null,
'textBody': '본문 $id',
'hasBlockedImages': false,
'imagesShown': false,
'isHtmlBodyOmitted': false,
'isBodyTruncated': false,
'attachments': <Object?>[],
'largeFiles': <Object?>[],
...?details[id],
if (showImages) ...?shownImageDetails[id],
};
}
/// 메일 하나를 [mailboxId] 에 두고 읽기 칸을 [detail] 로 덮는다
void put(
String mailboxId,
Map<String, Object?> message, {
Map<String, Object?> detail = const <String, Object?>{},
}) {
(messages[mailboxId] ??= <Map<String, Object?>>[]).add(message);
details[message['id']! as String] = detail;
}
/// 메일이 지금 든 메일함 (없으면 null)
String? mailboxOf(String id) => _find(id)?.mailboxId;
/// 메일의 지금 칸 (없으면 null)
Map<String, Object?>? messageOf(String id) => _find(id)?.message;
/// [mailboxId] 에 메일 [count] 통을 둔다 — id 는 `<prefix>1` 부터(앞이 최신)
void seed(
String mailboxId,
+24 -2
View File
@@ -1,15 +1,20 @@
import 'package:arasystem_app/app.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/core/router/app_router.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:arasystem_app/features/mail/data/mail_link_launcher.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
import 'fake_webview.dart';
import 'fakes.dart';
import 'file_fakes.dart';
import 'mail_fakes.dart';
import 'provider_harness.dart';
@@ -19,12 +24,22 @@ const String kMailTestAccount = 'ara.kim@arasystem.it.kr';
/// 휴대폰 화면 크기 (보드의 390 × 844)
const Size kPhoneSize = Size(390, 844);
/// 메일 화면 시험대 — 로그인한 앱 전체를 가짜 서버(인증 + 메일)로 띄운다
/// 메일 화면 시험대 — 로그인한 앱 전체를 가짜 서버(인증 + 메일)로 띄운다.
///
/// 기기에 기대는 것은 가짜로 바꾼다: WebView([webView]), 받은 파일 보관([files]) · 다른 앱으로 열기([opener]),
/// 바깥 브라우저([links]).
class MailAppHarness {
MailAppHarness();
final ProviderHarness base = ProviderHarness();
late final FakeMailBackend mail = FakeMailBackend(base.server);
final FakeWebViewPlatform webView = FakeWebViewPlatform();
final MemoryFileStore files = MemoryFileStore();
final RecordingFileOpener opener = RecordingFileOpener();
final RecordingLinkLauncher links = RecordingLinkLauncher();
/// 시험마다 더하는 덮어쓰기 — [pump] 전에 채운다
final List<Override> overrides = <Override>[];
FakeServer get server => base.server;
ManualClock get clock => base.clock;
@@ -47,6 +62,7 @@ class MailAppHarness {
tester.view.physicalSize = size * 3;
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
WebViewPlatform.instance = webView;
await base.prepare(refreshToken: 'refresh-1');
server.on(
'POST',
@@ -61,7 +77,13 @@ class MailAppHarness {
await tester.pumpWidget(
ProviderScope(
retry: noAutomaticRetry,
overrides: <Override>[...base.overrides],
overrides: <Override>[
...base.overrides,
downloadedFileStoreProvider.overrideWithValue(files),
fileOpenerProvider.overrideWithValue(opener),
mailLinkLauncherProvider.overrideWithValue(links.call),
...overrides,
],
child: const App(),
),
);