import 'dart:convert'; import 'dart:io'; import 'package:mailer/mailer.dart'; import 'package:mailer/smtp_server.dart'; import 'package:shared_preferences/shared_preferences.dart'; class EmailSenderConfig { final String host; final int port; final String username; final String password; final bool useTls; final bool ignoreBadCert; final List bcc; const EmailSenderConfig({ required this.host, required this.port, required this.username, required this.password, this.useTls = true, this.ignoreBadCert = false, this.bcc = const [], }); bool get isValid => host.isNotEmpty && username.isNotEmpty && password.isNotEmpty; } class EmailSender { static const _kCryptKey = 'test'; static const _kLogsKey = 'smtp_logs'; static const int _kMaxLogLines = 1000; static List parseBcc(String raw) { return raw .split(RegExp('[,\n]')) .map((s) => s.trim()) .where((s) => s.isNotEmpty) .toList(); } static String decrypt(String cipher) { if (cipher.isEmpty) return ''; try { final ob = base64Decode(cipher); final kb = utf8.encode(_kCryptKey); final pb = List.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]); return utf8.decode(pb); } catch (_) { return cipher; } } static Future _appendLog(String line) async { final prefs = await SharedPreferences.getInstance(); final now = DateTime.now().toIso8601String(); final entry = '[$now] $line'; final existing = List.from(prefs.getStringList(_kLogsKey) ?? const []); existing.add(entry); if (existing.length > _kMaxLogLines) { final dropCount = existing.length - _kMaxLogLines; existing.removeRange(0, dropCount); } await prefs.setStringList(_kLogsKey, existing); } static Future> loadLogs() async { final prefs = await SharedPreferences.getInstance(); return prefs.getStringList(_kLogsKey) ?? []; } static Future clearLogs() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_kLogsKey); } static Future _checkPortOpen(String host, int port, {Duration timeout = const Duration(seconds: 5)}) async { try { final socket = await Socket.connect(host, port, timeout: timeout); await socket.close(); await _appendLog('[TEST][PORT][OK] $host:$port reachable'); return true; } catch (e) { await _appendLog('[TEST][PORT][NG] $host:$port err=$e'); return false; } } static Future _checkAndLogConfig({required EmailSenderConfig config, required String channel}) async { final checks = { 'host': config.host.isNotEmpty, 'port': config.port > 0, 'user': config.username.isNotEmpty, 'pass': config.password.isNotEmpty, 'bcc': config.bcc.isNotEmpty, }; String valMask(String key) { switch (key) { case 'host': return config.host; case 'port': return config.port.toString(); case 'user': return config.username; case 'pass': return config.password.isNotEmpty ? '***' : ''; case 'bcc': return config.bcc.join(','); default: return ''; } } final summary = checks.entries .map((e) => '${e.key}=${valMask(e.key)} (${e.value ? 'OK' : 'NG'})') .join(' | '); final tail = 'tls=${config.useTls} ignoreBadCert=${config.ignoreBadCert}'; await _appendLog('[$channel][CFG] $summary | $tail'); return checks.values.every((v) => v); } static SmtpServer _serverFromConfig(EmailSenderConfig config) { return SmtpServer( config.host, port: config.port, username: config.username, password: config.password, ssl: !config.useTls, allowInsecure: config.ignoreBadCert || !config.useTls, ignoreBadCertificate: config.ignoreBadCert, ); } static Future loadConfigFromPrefs() async { final prefs = await SharedPreferences.getInstance(); final host = (prefs.getString('smtp_host') ?? '').trim(); final portStr = (prefs.getString('smtp_port') ?? '587').trim(); final user = (prefs.getString('smtp_user') ?? '').trim(); final passEncrypted = prefs.getString('smtp_pass') ?? ''; final pass = decrypt(passEncrypted).trim(); final useTls = prefs.getBool('smtp_tls') ?? true; final ignoreBadCert = prefs.getBool('smtp_ignore_bad_cert') ?? false; final bccRaw = prefs.getString('smtp_bcc') ?? ''; final bccList = parseBcc(bccRaw); final port = int.tryParse(portStr) ?? 587; final config = EmailSenderConfig( host: host, port: port, username: user, password: pass, useTls: useTls, ignoreBadCert: ignoreBadCert, bcc: bccList, ); if (!config.isValid) { await _appendLog('[CFG][NG] host/user/pass が未入力の可能性があります'); return null; } return config; } static Future sendTest({required EmailSenderConfig config}) async { final server = _serverFromConfig(config); final message = Message() ..from = Address(config.username) ..bccRecipients = config.bcc ..subject = 'SMTPテスト送信' ..text = 'これはテストメールです(BCC送信)'; final configOk = await _checkAndLogConfig(config: config, channel: 'TEST'); if (!configOk) { throw StateError('SMTP設定が不足しています'); } await _checkPortOpen(config.host, config.port); try { await send(message, server); await _appendLog('[TEST][OK] bcc: ${config.bcc.join(',')}'); } catch (e) { await _appendLog('[TEST][NG] err=$e (認証/暗号化設定を確認してください)'); rethrow; } } static Future sendInvoiceEmail({ required EmailSenderConfig config, required String toEmail, required File pdfFile, String? subject, String? attachmentFileName, String? body, }) async { final server = _serverFromConfig(config); final message = Message() ..from = Address(config.username) ..recipients = [toEmail] ..bccRecipients = config.bcc ..subject = subject ?? '請求書送付' ..text = body ?? '請求書をお送りします。ご確認ください。' ..attachments = [ FileAttachment(pdfFile) ..fileName = attachmentFileName ?? 'invoice.pdf' ..contentType = 'application/pdf' ]; final configOk = await _checkAndLogConfig(config: config, channel: 'INVOICE'); if (!configOk) { throw StateError('SMTP設定が不足しています'); } try { await send(message, server); await _appendLog('[INVOICE][OK] to: $toEmail bcc: ${config.bcc.join(',')}'); } catch (e) { await _appendLog('[INVOICE][NG] to: $toEmail err: $e'); rethrow; } } static Future logDeviceMailer({ required bool success, required String toEmail, required List bcc, String? error, }) async { final status = success ? 'OK' : 'NG'; final buffer = StringBuffer('[DEVICE][$status] to: $toEmail bcc: ${bcc.join(',')}'); if (error != null && error.isNotEmpty) { buffer.write(' err: $error'); } await _appendLog(buffer.toString()); } }