From 14c86fc6a50b7e12a04fffe9e81641b6e91d1c07 Mon Sep 17 00:00:00 2001 From: Sherlock Yin Date: Sat, 28 Feb 2026 00:54:31 +0800 Subject: [PATCH 1/3] feat: implement email notification via FastMail gateway --- Sources/VoiceMemo/ContentView.swift | 9 -- Sources/VoiceMemo/Services/EmailService.swift | 108 ++++++++++++++++++ .../Services/MeetingPipelineManager.swift | 91 ++++++++++++--- .../VoiceMemo/Services/SettingsStore.swift | 33 ++++++ Sources/VoiceMemo/Views/RecordingView.swift | 2 +- Sources/VoiceMemo/Views/ResultView.swift | 53 +++++++++ Sources/VoiceMemo/Views/SettingsView.swift | 77 +++++++++++++ doc/10-email-notification.md | 55 +++++++++ doc/10-email-notification.zh-CN.md | 55 +++++++++ openspec/specs/email-notification/design.md | 58 ++++++++++ openspec/specs/email-notification/spec.md | 63 ++++++++++ 11 files changed, 580 insertions(+), 24 deletions(-) create mode 100644 Sources/VoiceMemo/Services/EmailService.swift create mode 100644 doc/10-email-notification.md create mode 100644 doc/10-email-notification.zh-CN.md create mode 100644 openspec/specs/email-notification/design.md create mode 100644 openspec/specs/email-notification/spec.md diff --git a/Sources/VoiceMemo/ContentView.swift b/Sources/VoiceMemo/ContentView.swift index 225a336..c3f5c8e 100644 --- a/Sources/VoiceMemo/ContentView.swift +++ b/Sources/VoiceMemo/ContentView.swift @@ -21,7 +21,6 @@ struct ContentView: View { init(settings: SettingsStore) { self.settings = settings _recorder = StateObject(wrappedValue: AudioRecorder(settings: settings)) - _selectedRecordingMode = State(initialValue: RecordingModeItem(rawValue: settings.recordingMode.rawValue) ?? .mixed) } // Method to navigate to a task in history @@ -154,14 +153,6 @@ struct ContentView: View { .onChange(of: recorder.latestTask?.id) { _ in Task { await historyStore.refresh() } } - .onChange(of: selectedRecordingMode) { newValue in - if let newValue { - settings.recordingMode = SettingsStore.RecordingMode(rawValue: newValue.rawValue) ?? .mixed - } - } - .onChange(of: settings.recordingMode) { newValue in - selectedRecordingMode = RecordingModeItem(rawValue: newValue.rawValue) ?? .mixed - } .alert("Import Failed", isPresented: Binding( get: { importError != nil }, set: { if !$0 { importError = nil } } diff --git a/Sources/VoiceMemo/Services/EmailService.swift b/Sources/VoiceMemo/Services/EmailService.swift new file mode 100644 index 0000000..2c040f8 --- /dev/null +++ b/Sources/VoiceMemo/Services/EmailService.swift @@ -0,0 +1,108 @@ +import Foundation + +enum EmailError: Error, LocalizedError { + case invalidURL + case missingConfiguration + case serverError(statusCode: Int) + case networkError(Error) + case invalidResponse + + var errorDescription: String? { + switch self { + case .invalidURL: return "Invalid Gateway URL" + case .missingConfiguration: return "Missing email configuration (URL, Token, or Recipient)" + case .serverError(let code): return "Email server returned error: \(code)" + case .networkError(let error): return "Network error: \(error.localizedDescription)" + case .invalidResponse: return "Invalid response from server" + } + } +} + +class EmailService { + private let settings: SettingsStore + + init(settings: SettingsStore) { + self.settings = settings + } + + func sendEmail(subject: String, body: String, attachmentPath: String?) async throws { + // 1. Validation + let gatewayUrlString = settings.fastmailUrl + let token = settings.getFastmailToken() + let recipient = settings.recipientEmail + + guard !gatewayUrlString.isEmpty, + let token = token, !token.isEmpty, + !recipient.isEmpty else { + throw EmailError.missingConfiguration + } + + guard let url = URL(string: gatewayUrlString.trimmingCharacters(in: .whitespacesAndNewlines))?.appendingPathComponent("/api/v1/send") else { + throw EmailError.invalidURL + } + + // 2. Build Request + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let boundary = "Boundary-\(UUID().uuidString)" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var httpBody = Data() + + // Helper to append strings + func append(_ string: String) { + if let data = string.data(using: .utf8) { + httpBody.append(data) + } + } + + // Helper to append fields + func appendField(name: String, value: String) { + append("--\(boundary)\r\n") + append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n") + append("\(value)\r\n") + } + + // Add Fields + appendField(name: "to", value: recipient) + appendField(name: "subject", value: subject) + appendField(name: "body", value: body) + + // Add Attachment + if let attachmentPath = attachmentPath, FileManager.default.fileExists(atPath: attachmentPath) { + let fileUrl = URL(fileURLWithPath: attachmentPath) + let filename = fileUrl.lastPathComponent + let mimeType = "text/markdown" + + if let fileData = try? Data(contentsOf: fileUrl) { + append("--\(boundary)\r\n") + append("Content-Disposition: form-data; name=\"attachments\"; filename=\"\(filename)\"\r\n") + append("Content-Type: \(mimeType)\r\n\r\n") + httpBody.append(fileData) + append("\r\n") + } + } + + append("--\(boundary)--\r\n") + request.httpBody = httpBody + + // 3. Send Request + do { + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw EmailError.invalidResponse + } + + if !(200...299).contains(httpResponse.statusCode) { + throw EmailError.serverError(statusCode: httpResponse.statusCode) + } + } catch let error as EmailError { + throw error + } catch { + throw EmailError.networkError(error) + } + } +} diff --git a/Sources/VoiceMemo/Services/MeetingPipelineManager.swift b/Sources/VoiceMemo/Services/MeetingPipelineManager.swift index 5f1c3c6..c8b0e9e 100644 --- a/Sources/VoiceMemo/Services/MeetingPipelineManager.swift +++ b/Sources/VoiceMemo/Services/MeetingPipelineManager.swift @@ -123,6 +123,8 @@ class MeetingPipelineManager: ObservableObject { await MainActor.run { self.isProcessing = true } + var isChainCompleted = true + for node in nodes { // Update UI status to "Running" await updateStatus(node.step, isFailed: false) @@ -147,53 +149,114 @@ class MeetingPipelineManager: ObservableObject { retryCount += 1 if retryCount > PipelineConstants.maxPollingRetries { await updateStatus(.failed, step: node.step, error: "Polling timeout", isFailed: true) + isChainCompleted = false return } try? await Task.sleep(nanoseconds: PipelineConstants.pollingInterval) continue case .channelNotFound(let id): await updateStatus(.failed, step: node.step, error: "Channel \(id) not found", isFailed: true) + isChainCompleted = false return case .inputMissing(let msg): await updateStatus(.failed, step: node.step, error: "Input missing: \(msg)", isFailed: true) + isChainCompleted = false return case .transcodeFailed: await updateStatus(.failed, step: node.step, error: "Transcoding failed", isFailed: true) + isChainCompleted = false return case .cloudError(let msg): await updateStatus(.failed, step: node.step, error: "Cloud service error: \(msg)", isFailed: true) + isChainCompleted = false return case .taskFailed(let msg): await updateStatus(.failed, step: node.step, error: "Task failed: \(msg)", isFailed: true) + isChainCompleted = false return } } if let transcriptionError = error as? TranscriptionError { - await updateStatus(.failed, step: node.step, error: formatTranscriptionError(transcriptionError), isFailed: true) + await updateStatus(.failed, step: node.step, error: "Transcription Error: \(transcriptionError.localizedDescription)", isFailed: true) + isChainCompleted = false return } - // Backward compatibility: handle non-PipelineError NSError - let nsError = error as NSError - if nsError.code == 202 { - retryCount += 1 - if retryCount > PipelineConstants.maxPollingRetries { - await updateStatus(.failed, step: node.step, error: "Polling timeout", isFailed: true) - return - } - try? await Task.sleep(nanoseconds: PipelineConstants.pollingInterval) - continue - } - - // Unknown error await updateStatus(.failed, step: node.step, error: error.localizedDescription, isFailed: true) + isChainCompleted = false return } } } await MainActor.run { self.isProcessing = false } + + // Post-processing: Send Email + if isChainCompleted && settings.enableEmailNotification { + await sendEmailNotification() + } + } + + private func sendEmailNotification() async { + await MainActor.run { self.isProcessing = true } + + // Generate Markdown + let mdContent = generateMarkdownForEmail() + let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent("\(task.title).md") + + do { + try mdContent.write(to: tempUrl, atomically: true, encoding: .utf8) + + let emailService = EmailService(settings: settings) + try await emailService.sendEmail( + subject: "Meeting Summary: \(task.title)", + body: "Please find the attached meeting summary.", + attachmentPath: tempUrl.path + ) + settings.log("Email sent successfully to \(settings.recipientEmail)") + } catch { + settings.log("Failed to send email: \(error.localizedDescription)") + await MainActor.run { + self.errorMessage = "Email failed: \(error.localizedDescription)" + } + } + + // Clean up + try? FileManager.default.removeItem(at: tempUrl) + + await MainActor.run { self.isProcessing = false } + } + + private func generateMarkdownForEmail() -> String { + var md = "# \(task.title)\n\n" + md += "Date: \(task.createdAt)\n\n" + + // Metadata + md += "## Task Info\n" + if let key = task.taskKey { md += "- Task Key: \(key)\n" } + if let status = task.apiStatus { md += "- Status: \(status)\n" } + if let duration = task.bizDuration { md += "- Duration: \(duration / 1000)s\n" } + if let mp3 = task.outputMp3Path { md += "- Audio: [Download](\(mp3))\n" } + md += "\n" + + if let summary = task.summary { + md += "## Summary\n\(summary)\n\n" + } + + if let keyPoints = task.keyPoints { + md += "## Key Points\n\(keyPoints)\n\n" + } + + if let actionItems = task.actionItems { + md += "## Action Items\n\(actionItems)\n\n" + } + + if let transcript = task.transcript { + md += "## Transcript\n\(transcript)\n" + } + + return md } // MARK: - Hydration & Persistence diff --git a/Sources/VoiceMemo/Services/SettingsStore.swift b/Sources/VoiceMemo/Services/SettingsStore.swift index 114dec2..b3f73aa 100644 --- a/Sources/VoiceMemo/Services/SettingsStore.swift +++ b/Sources/VoiceMemo/Services/SettingsStore.swift @@ -112,6 +112,18 @@ class SettingsStore: ObservableObject { didSet { UserDefaults.standard.set(recordingMode.rawValue, forKey: "recordingMode") } } + // FastMail Config + @Published var fastmailUrl: String { + didSet { UserDefaults.standard.set(fastmailUrl, forKey: "fastmailUrl") } + } + @Published var recipientEmail: String { + didSet { UserDefaults.standard.set(recipientEmail, forKey: "recipientEmail") } + } + @Published var enableEmailNotification: Bool { + didSet { UserDefaults.standard.set(enableEmailNotification, forKey: "enableEmailNotification") } + } + @Published var hasFastmailToken: Bool = false + // Audio Recording Config @Published var savePathBookmark: Data? { didSet { UserDefaults.standard.set(savePathBookmark, forKey: "savePathBookmark") } @@ -168,6 +180,11 @@ class SettingsStore: ObservableObject { self.savePathBookmark = UserDefaults.standard.data(forKey: "savePathBookmark") self.useKeychain = UserDefaults.standard.object(forKey: "useKeychain") as? Bool ?? true + // FastMail + self.fastmailUrl = UserDefaults.standard.string(forKey: "fastmailUrl") ?? "" + self.recipientEmail = UserDefaults.standard.string(forKey: "recipientEmail") ?? "" + self.enableEmailNotification = UserDefaults.standard.object(forKey: "enableEmailNotification") as? Bool ?? false + migrateLegacySecrets() checkSecrets() log("SettingsStore initialized (system). Storage type: \(storageType), ASR Provider: \(asrProvider)") @@ -229,6 +246,7 @@ class SettingsStore: ObservableObject { hasMySQLPassword = getSecret(key: "mysql_password") != nil hasVolcAccessToken = getSecret(key: "volc_access_token") != nil + hasFastmailToken = getSecret(key: "fastmail_token") != nil } func saveMySQLPassword(_ value: String) { @@ -302,10 +320,25 @@ class SettingsStore: ObservableObject { checkSecrets() } + func saveFastmailToken(_ value: String) { + saveSecret(value, key: "fastmail_token") + checkSecrets() + } + + func getFastmailToken() -> String? { + return getSecret(key: "fastmail_token") + } + + func clearFastmailSecrets() { + deleteSecret(key: "fastmail_token") + checkSecrets() + } + func clearSecrets() { clearTingwuSecrets() clearOSSSecrets() clearVolcSecrets() + clearFastmailSecrets() checkSecrets() } diff --git a/Sources/VoiceMemo/Views/RecordingView.swift b/Sources/VoiceMemo/Views/RecordingView.swift index 81b037f..9a688ae 100644 --- a/Sources/VoiceMemo/Views/RecordingView.swift +++ b/Sources/VoiceMemo/Views/RecordingView.swift @@ -144,7 +144,7 @@ struct RecordingView: View { ) } .buttonStyle(.plain) - .disabled(settings.recordingMode != .localOnly && recorder.selectedApp == nil) + .disabled(recorder.selectedApp == nil) .keyboardShortcut("R", modifiers: .command) } diff --git a/Sources/VoiceMemo/Views/ResultView.swift b/Sources/VoiceMemo/Views/ResultView.swift index 40aa2d6..07fbcfe 100644 --- a/Sources/VoiceMemo/Views/ResultView.swift +++ b/Sources/VoiceMemo/Views/ResultView.swift @@ -6,6 +6,8 @@ struct ResultView: View { let settings: SettingsStore @ObservedObject var playback: AudioPlaybackController @State private var selectedTab: ResultTab = .overview + @State private var isSendingEmail = false + @State private var emailStatus: String? @Namespace private var animationNamespace enum ResultTab: String, CaseIterable, Identifiable { @@ -44,6 +46,23 @@ struct ResultView: View { Spacer() + if settings.enableEmailNotification && task.status == .completed { + Button(action: { + Task { await sendEmail() } + }) { + HStack(spacing: 4) { + if isSendingEmail { + ProgressView().controlSize(.small) + } else { + Image(systemName: "envelope") + } + Text(emailStatus ?? "Email") + } + } + .disabled(isSendingEmail) + .help("Send meeting summary via email") + } + Button(action: exportMarkdown) { Label("Export", systemImage: "square.and.arrow.up") } @@ -112,6 +131,40 @@ struct ResultView: View { } } + private func sendEmail() async { + isSendingEmail = true + emailStatus = "Sending..." + + // Generate Markdown + let mdContent = generateMarkdown() + let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent("\(task.title).md") + + do { + try mdContent.write(to: tempUrl, atomically: true, encoding: .utf8) + + let emailService = EmailService(settings: settings) + try await emailService.sendEmail( + subject: "Meeting Summary: \(task.title)", + body: "Please find the attached meeting summary.", + attachmentPath: tempUrl.path + ) + emailStatus = "Sent" + } catch { + emailStatus = "Failed" + // Show error in a more prominent way if needed, or just log + print("Failed to send email: \(error.localizedDescription)") + } + + // Clean up + try? FileManager.default.removeItem(at: tempUrl) + + isSendingEmail = false + + // Reset status after a delay + try? await Task.sleep(nanoseconds: 3 * 1_000_000_000) + emailStatus = nil + } + private func exportMarkdown() { let panel = NSSavePanel() panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText] diff --git a/Sources/VoiceMemo/Views/SettingsView.swift b/Sources/VoiceMemo/Views/SettingsView.swift index 04b57bc..a497ffa 100644 --- a/Sources/VoiceMemo/Views/SettingsView.swift +++ b/Sources/VoiceMemo/Views/SettingsView.swift @@ -2,6 +2,12 @@ import SwiftUI import AppKit struct SettingsView: View { + private enum SettingsCategory: String, CaseIterable { + case general, asr, oss, storage, logs, email + } + + // MARK: - Properties + @ObservedObject var settings: SettingsStore @ObservedObject var storageManager = StorageManager.shared var category: SettingsCategory? @@ -14,6 +20,8 @@ struct SettingsView: View { @State private var mysqlPasswordInput: String = "" @State private var testStatus: String = "" @State private var mysqlTestStatus: String = "" + @State private var fastmailTokenInput: String = "" + @State private var emailTestStatus: String = "" @State private var showingLog = false init(settings: SettingsStore, category: SettingsCategory? = nil) { @@ -34,6 +42,8 @@ struct SettingsView: View { ossForm case .storage: storageForm + case .email: + emailForm case .logs: logsForm } @@ -43,6 +53,7 @@ struct SettingsView: View { asrForm.tabItem { Text("ASR") } ossForm.tabItem { Text("OSS") } storageForm.tabItem { Text("Storage") } + emailForm.tabItem { Text("Email") } logsForm.tabItem { Text("Logs") } } } @@ -594,6 +605,72 @@ struct SettingsView: View { } } + private var emailForm: some View { + VStack(spacing: Layout.standardSpacing) { + StyledGroupBox("FastMail Gateway") { + FormRow(label: "Enable") { + Toggle("Enable Email Notification", isOn: $settings.enableEmailNotification) + .toggleStyle(.switch) + .labelsHidden() + Spacer() + } + + if settings.enableEmailNotification { + FormRow(label: "Gateway URL") { + TextField("e.g. http://localhost:8080", text: $settings.fastmailUrl) + .textFieldStyle(.roundedBorder) + } + + FormRow(label: "Recipient") { + TextField("Email address", text: $settings.recipientEmail) + .textFieldStyle(.roundedBorder) + } + + FormRow(label: "Token") { + CredentialRow( + hasValue: settings.hasFastmailToken, + input: $fastmailTokenInput, + placeholder: "Bearer Token", + isSecure: true, + onSave: { settings.saveFastmailToken(fastmailTokenInput) }, + onClear: { settings.clearFastmailSecrets() } + ) + } + + Divider() + + FormRow(label: "Actions") { + HStack { + Button("Test Email") { + Task { await testEmail() } + } + if !emailTestStatus.isEmpty { + Text(emailTestStatus) + .font(.caption) + .foregroundColor(emailTestStatus.contains("Success") ? .green : .red) + } + } + } + } + } + } + } + + private func testEmail() async { + emailTestStatus = "Testing..." + let service = EmailService(settings: settings) + do { + try await service.sendEmail( + subject: "Test Email from VoiceMemo", + body: "This is a test email to verify your configuration.", + attachmentPath: nil + ) + emailTestStatus = "Success: Email sent" + } catch { + emailTestStatus = "Failed: \(error.localizedDescription)" + } + } + private func testUpload() async { testStatus = "Testing..." settings.log("OSS test upload start") diff --git a/doc/10-email-notification.md b/doc/10-email-notification.md new file mode 100644 index 0000000..804aea8 --- /dev/null +++ b/doc/10-email-notification.md @@ -0,0 +1,55 @@ +# Email Notification Integration + +## Overview +This document outlines the integration of automated email notifications using the [FastMail Gateway](https://github.com/mistbit/fastmail). + +## Feature Description +The system will automatically generate a Markdown summary of the meeting and send it via email to a configured recipient upon successful completion of the processing pipeline. + +## Configuration +Users can configure the email gateway in the application settings: + +- **Gateway URL**: The endpoint of the deployed FastMail service (e.g., `http://localhost:8080`). +- **Authentication Token**: The secure token for accessing the gateway. +- **Recipient Email**: The email address where the summary should be sent. + +## Workflow + +1. **Pipeline Completion**: + - The `MeetingPipelineManager` detects that the transcription and summarization tasks are complete. + - If email notification is enabled, the system proceeds to generate the Markdown file. + +2. **Markdown Generation**: + - The system generates a Markdown file containing: + - Meeting Metadata (Title, Date, Duration) + - Summary + - Key Points + - Action Items + - Full Transcript + +3. **Email Dispatch**: + - The system constructs a multipart HTTP POST request to the configured Gateway URL. + - The request includes the Markdown file as an attachment. + - The email is sent to the configured recipient. + +4. **Status Feedback**: + - The pipeline status reflects the outcome of the email sending process (Success/Failure). + - In case of failure, users can retry sending the email manually from the Result View. + +## Technical Implementation Plan + +### 1. Settings Update +- Extend `SettingsStore` to include `fastmailUrl`, `fastmailToken`, and `recipientEmail`. +- Update `SettingsView` to provide input fields for these configurations. + +### 2. Service Layer +- Create `EmailService` to handle communication with the FastMail gateway. +- Implement `sendEmail(to:subject:body:attachments:)` method using `URLSession`. + +### 3. Pipeline Integration +- Introduce a new pipeline node `EmailNode` (or extend `MeetingPipelineManager` logic). +- Ensure this step runs only after successful completion of previous steps. + +### 4. UI Enhancements +- Add a "Send Email" button in `ResultView` for manual triggering. +- Display email sending status in the pipeline progress indicator. diff --git a/doc/10-email-notification.zh-CN.md b/doc/10-email-notification.zh-CN.md new file mode 100644 index 0000000..c564f47 --- /dev/null +++ b/doc/10-email-notification.zh-CN.md @@ -0,0 +1,55 @@ +# 邮件通知集成 + +## 概述 +本文档概述了使用 [FastMail Gateway](https://github.com/mistbit/fastmail) 集成自动邮件通知的功能。 + +## 功能描述 +当处理流水线成功完成后,系统将自动生成会议的 Markdown 摘要,并通过邮件发送给配置的收件人。 + +## 配置 +用户可以在应用设置中配置邮件网关: + +- **网关 URL (Gateway URL)**:部署的 FastMail 服务端点(例如 `http://localhost:8080`)。 +- **认证令牌 (Authentication Token)**:用于访问网关的安全令牌。 +- **收件人邮箱 (Recipient Email)**:接收摘要的邮箱地址。 + +## 工作流程 + +1. **流水线完成**: + - `MeetingPipelineManager` 检测到转写和摘要任务已完成。 + - 如果启用了邮件通知,系统将开始生成 Markdown 文件。 + +2. **Markdown 生成**: + - 系统生成包含以下内容的 Markdown 文件: + - 会议元数据(标题、日期、时长) + - 摘要 + - 关键点 + - 待办事项 + - 完整转写文本 + +3. **邮件分发**: + - 系统构造一个 multipart HTTP POST 请求到配置的网关 URL。 + - 请求包含作为附件的 Markdown 文件。 + - 邮件将被发送到配置的收件人。 + +4. **状态反馈**: + - 流水线状态反映邮件发送过程的结果(成功/失败)。 + - 如果失败,用户可以在结果视图中手动重试发送邮件。 + +## 技术实现计划 + +### 1. 设置更新 +- 扩展 `SettingsStore` 以包含 `fastmailUrl`、`fastmailToken` 和 `recipientEmail`。 +- 更新 `SettingsView` 以提供这些配置的输入字段。 + +### 2. 服务层 +- 创建 `EmailService` 以处理与 FastMail 网关的通信。 +- 使用 `URLSession` 实现 `sendEmail(to:subject:body:attachments:)` 方法。 + +### 3. 流水线集成 +- 引入新的流水线节点 `EmailNode`(或扩展 `MeetingPipelineManager` 逻辑)。 +- 确保此步骤仅在之前步骤成功完成后运行。 + +### 4. UI 增强 +- 在 `ResultView` 中添加“发送邮件”按钮以进行手动触发。 +- 在流水线进度指示器中显示邮件发送状态。 diff --git a/openspec/specs/email-notification/design.md b/openspec/specs/email-notification/design.md new file mode 100644 index 0000000..494b447 --- /dev/null +++ b/openspec/specs/email-notification/design.md @@ -0,0 +1,58 @@ +# Design: Email Notification via FastMail Gateway + +## Architecture +The email notification feature will be integrated into the existing `MeetingPipelineManager` workflow as a new step (Node) or a post-processing action. + +### Components + +1. **SettingsStore**: + - Add `fastmailUrl` (String) + - Add `fastmailToken` (String, Secure) + - Add `recipientEmail` (String) + - Add `enableEmailNotification` (Bool) + +2. **EmailService**: + - Responsible for constructing the multipart request to the FastMail gateway. + - Handles authentication (Bearer Token). + - Handles attachment upload. + +3. **PipelineNode (New: `EmailNode`)**: + - Step: `.sendingEmail` (New status?) or part of `.completed` post-processing. + - Logic: + - Check if email is enabled and configured. + - Generate Markdown content. + - Call `EmailService.send()`. + - Update task status or log result. + +4. **MeetingTask**: + - Add `emailStatus` (Enum: .none, .sending, .sent, .failed) + - Add `emailError` (String?) + +### Workflow + +1. **Configuration**: User enters FastMail details in Settings. +2. **Pipeline Execution**: + - After `PollingNode` completes successfully (Status: `.completed`), the pipeline manager checks if `enableEmailNotification` is true. + - If true, it triggers the email sending logic. + - **Option A**: Add `EmailNode` to the end of the chain. + - **Option B**: Handle it in `MeetingPipelineManager.executeChain` after the loop finishes. + - *Decision*: Option A is cleaner and fits the "Pipeline" pattern. We can add a new status `.sendingEmail` -> `.emailSent`. + +### UI Changes + +1. **SettingsView**: + - Add a section for "Email Notification". + - Fields: Gateway URL, Token, Recipient Email. + - "Test Connection" button. + +2. **ResultView**: + - Add "Send Email" button (manual trigger) if task is completed. + - Show email status (e.g., "Email Sent" or "Sending Failed"). + +## Data Flow + +`MeetingTask` -> `MeetingPipelineManager` -> `EmailNode` -> `EmailService` -> `FastMail Gateway` + +## Security +- `fastmailToken` stored in Keychain via `KeychainHelper`. +- HTTPS recommended for Gateway URL. diff --git a/openspec/specs/email-notification/spec.md b/openspec/specs/email-notification/spec.md new file mode 100644 index 0000000..bb5802a --- /dev/null +++ b/openspec/specs/email-notification/spec.md @@ -0,0 +1,63 @@ +# Spec: Email Notification via FastMail Gateway + +## Purpose +Automate the sending of meeting summaries (Markdown) via email upon successful pipeline completion, using the `fastmail` gateway. + +## Context +Currently, users must manually export Markdown files after the pipeline finishes. This feature automates the delivery of the meeting summary to a designated recipient. + +## Requirements + +### Requirement: Email Gateway Configuration +The system SHALL support configuration for the `fastmail` gateway. + +#### Scenario: User configures email settings +- **WHEN** the user navigates to the Settings view +- **THEN** the user MUST be able to input: + - `FastMail Gateway URL` (e.g., `http://localhost:8080`) + - `FastMail Token` (for authentication) + - `Recipient Email` (the default "corresponding user" email) +- **AND** these settings MUST be persisted securely (Token in Keychain). + +### Requirement: Automated Email Sending +The system SHALL automatically send an email with the meeting summary upon successful pipeline completion. + +#### Scenario: Pipeline completes successfully +- **WHEN** the meeting pipeline reaches the `completed` state (after transcription and summarization) +- **AND** the `FastMail Gateway URL` and `Recipient Email` are configured +- **THEN** the system MUST generate the Markdown summary of the meeting +- **AND** the system MUST send a POST request to the configured gateway URL + - **Endpoint**: `/api/v1/send` (based on `fastmail` API) + - **Headers**: `Authorization: Bearer ` + - **Body**: + - `to`: `` + - `subject`: `Meeting Summary: ` + - `body`: "Please find the attached meeting summary." (or the summary content itself if preferred) + - `attachments`: The generated Markdown file +- **AND** the pipeline status MUST reflect the email sending result (e.g., log success or error). + +#### Scenario: Pipeline fails or is incomplete +- **WHEN** the pipeline is in any state other than `completed` +- **THEN** the system MUST NOT attempt to send the email. + +#### Scenario: Email sending fails +- **WHEN** the email gateway returns an error or is unreachable +- **THEN** the system MUST log the error +- **AND** the UI SHOULD indicate that the email failed to send (optional: allow retry). + +### Requirement: Manual Trigger (Optional but recommended) +The system SHOULD allow manual triggering of the email if it failed or was skipped. + +#### Scenario: User clicks "Send Email" +- **WHEN** the task is completed +- **THEN** the "Export Markdown" area SHOULD include an option to "Send via Email". + +## API Integration Details +Based on `https://github.com/mistbit/fastmail`: +- **Method**: POST +- **Content-Type**: `multipart/form-data` +- **Fields**: + - `to`: String (comma-separated emails) + - `subject`: String + - `body`: String (HTML supported) + - `attachments`: File (Multipart) From 31321b39333c3391c889e32173b4600b06b54b88 Mon Sep 17 00:00:00 2001 From: Sherlock Yin Date: Sat, 28 Feb 2026 01:06:36 +0800 Subject: [PATCH 2/3] refactor: unify markdown generation and fix settings category conflict - Extract markdown generation to MeetingTask - Fix SettingsCategory enum conflict in SettingsView - Improve email notification trigger logic in pipeline - Clean up unused comments and improve logging --- Sources/VoiceMemo/Models/AppNavigation.swift | 4 + Sources/VoiceMemo/Models/MeetingTask.swift | 62 +++++++++++++++ Sources/VoiceMemo/Services/EmailService.swift | 7 -- .../Services/MeetingPipelineManager.swift | 46 ++---------- Sources/VoiceMemo/Views/ResultView.swift | 75 ++----------------- Sources/VoiceMemo/Views/SettingsView.swift | 4 - 6 files changed, 77 insertions(+), 121 deletions(-) diff --git a/Sources/VoiceMemo/Models/AppNavigation.swift b/Sources/VoiceMemo/Models/AppNavigation.swift index 629574a..7c1fec1 100644 --- a/Sources/VoiceMemo/Models/AppNavigation.swift +++ b/Sources/VoiceMemo/Models/AppNavigation.swift @@ -88,6 +88,7 @@ enum SettingsCategory: String, Hashable, CaseIterable, Identifiable { case asr case oss case storage + case email case logs var id: String { rawValue } @@ -98,6 +99,7 @@ enum SettingsCategory: String, Hashable, CaseIterable, Identifiable { case .asr: return "ASR Service" case .oss: return "Object Storage" case .storage: return "Storage" + case .email: return "Email" case .logs: return "Logs" } } @@ -108,6 +110,7 @@ enum SettingsCategory: String, Hashable, CaseIterable, Identifiable { case .asr: return "Configure Speech-to-Text providers and parameters." case .oss: return "Configure Object Storage Service (OSS) settings." case .storage: return "Manage data persistence and database connections." + case .email: return "Configure FastMail gateway and recipients." case .logs: return "View and manage application logs." } } @@ -118,6 +121,7 @@ enum SettingsCategory: String, Hashable, CaseIterable, Identifiable { case .asr: return "waveform" case .oss: return "server.rack" case .storage: return "externaldrive" + case .email: return "envelope" case .logs: return "doc.text" } } diff --git a/Sources/VoiceMemo/Models/MeetingTask.swift b/Sources/VoiceMemo/Models/MeetingTask.swift index 2b51510..757f192 100644 --- a/Sources/VoiceMemo/Models/MeetingTask.swift +++ b/Sources/VoiceMemo/Models/MeetingTask.swift @@ -161,3 +161,65 @@ extension MeetingTask { } } } + +extension MeetingTask { + func markdownSummary() -> String { + var md = "# \(title)\n\n" + md += "Date: \(createdAt)\n\n" + + md += "## Task Info\n" + if let key = taskKey { md += "- Task Key: \(key)\n" } + if let status = apiStatus { md += "- Status: \(status)\n" } + if let error = statusText, !error.isEmpty { md += "- Message: \(error)\n" } + if let duration = bizDuration { md += "- Duration: \(duration / 1000)s\n" } + if let mp3 = outputMp3Path { md += "- Audio: [Download](\(mp3))\n" } + md += "\n" + + if let summary = summary { + md += "## Summary\n\(summary)\n\n" + } + + if let keyPoints = keyPoints { + md += "## Key Points\n\(keyPoints)\n\n" + } + + if let actionItems = actionItems { + md += "## Action Items\n\(actionItems)\n\n" + } + + if let transcript = derivedTranscriptText() { + md += "## Transcript\n\(transcript)\n" + } + + return md + } + + func derivedTranscriptText() -> String? { + if let transcript = transcript, !transcript.isEmpty { + return transcript + } + + if let dataStr = transcriptData, + let data = dataStr.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let text = TranscriptParser.buildTranscriptText(from: json) { + return text + } + } + + guard let raw = rawResponse, + let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + return TranscriptParser.buildTranscriptText(from: json) + } + + func safeFilename() -> String { + let invalid = CharacterSet(charactersIn: "/:\\") + let parts = title.components(separatedBy: invalid) + let name = parts.joined(separator: "_").trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? "meeting-summary" : name + } +} diff --git a/Sources/VoiceMemo/Services/EmailService.swift b/Sources/VoiceMemo/Services/EmailService.swift index 2c040f8..100cb8e 100644 --- a/Sources/VoiceMemo/Services/EmailService.swift +++ b/Sources/VoiceMemo/Services/EmailService.swift @@ -26,7 +26,6 @@ class EmailService { } func sendEmail(subject: String, body: String, attachmentPath: String?) async throws { - // 1. Validation let gatewayUrlString = settings.fastmailUrl let token = settings.getFastmailToken() let recipient = settings.recipientEmail @@ -41,7 +40,6 @@ class EmailService { throw EmailError.invalidURL } - // 2. Build Request var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") @@ -51,26 +49,22 @@ class EmailService { var httpBody = Data() - // Helper to append strings func append(_ string: String) { if let data = string.data(using: .utf8) { httpBody.append(data) } } - // Helper to append fields func appendField(name: String, value: String) { append("--\(boundary)\r\n") append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n") append("\(value)\r\n") } - // Add Fields appendField(name: "to", value: recipient) appendField(name: "subject", value: subject) appendField(name: "body", value: body) - // Add Attachment if let attachmentPath = attachmentPath, FileManager.default.fileExists(atPath: attachmentPath) { let fileUrl = URL(fileURLWithPath: attachmentPath) let filename = fileUrl.lastPathComponent @@ -88,7 +82,6 @@ class EmailService { append("--\(boundary)--\r\n") request.httpBody = httpBody - // 3. Send Request do { let (_, response) = try await URLSession.shared.data(for: request) diff --git a/Sources/VoiceMemo/Services/MeetingPipelineManager.swift b/Sources/VoiceMemo/Services/MeetingPipelineManager.swift index c8b0e9e..9c118b1 100644 --- a/Sources/VoiceMemo/Services/MeetingPipelineManager.swift +++ b/Sources/VoiceMemo/Services/MeetingPipelineManager.swift @@ -116,7 +116,6 @@ class MeetingPipelineManager: ObservableObject { // MARK: - Board & Execution Logic private func executeChain(nodes: [PipelineNode]) async { - // 1. Hydrate Board from Task let taskSnapshot = await MainActor.run { self.task } var board = createBoard(from: taskSnapshot) let services = ServiceProvider(ossService: ossService, transcriptionService: transcriptionService) @@ -126,7 +125,6 @@ class MeetingPipelineManager: ObservableObject { var isChainCompleted = true for node in nodes { - // Update UI status to "Running" await updateStatus(node.step, isFailed: false) var success = false @@ -134,15 +132,12 @@ class MeetingPipelineManager: ObservableObject { while !success { do { - // 2. Run Node (Pure execution on Board) try await node.run(board: &board, services: services) - // 3. Persist State (Sync Board back to Task) await persistState(from: board, channelId: 0, completedStep: node.step) success = true } catch { - // Handle PipelineError with type safety if let pipelineError = error as? PipelineError { switch pipelineError { case .taskRunning: @@ -192,8 +187,7 @@ class MeetingPipelineManager: ObservableObject { await MainActor.run { self.isProcessing = false } - // Post-processing: Send Email - if isChainCompleted && settings.enableEmailNotification { + if isChainCompleted && settings.enableEmailNotification && task.status == .completed { await sendEmailNotification() } } @@ -201,9 +195,9 @@ class MeetingPipelineManager: ObservableObject { private func sendEmailNotification() async { await MainActor.run { self.isProcessing = true } - // Generate Markdown - let mdContent = generateMarkdownForEmail() - let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent("\(task.title).md") + let mdContent = task.markdownSummary() + let filename = task.safeFilename().appending(".md") + let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(filename) do { try mdContent.write(to: tempUrl, atomically: true, encoding: .utf8) @@ -222,42 +216,12 @@ class MeetingPipelineManager: ObservableObject { } } - // Clean up try? FileManager.default.removeItem(at: tempUrl) await MainActor.run { self.isProcessing = false } } - private func generateMarkdownForEmail() -> String { - var md = "# \(task.title)\n\n" - md += "Date: \(task.createdAt)\n\n" - - // Metadata - md += "## Task Info\n" - if let key = task.taskKey { md += "- Task Key: \(key)\n" } - if let status = task.apiStatus { md += "- Status: \(status)\n" } - if let duration = task.bizDuration { md += "- Duration: \(duration / 1000)s\n" } - if let mp3 = task.outputMp3Path { md += "- Audio: [Download](\(mp3))\n" } - md += "\n" - - if let summary = task.summary { - md += "## Summary\n\(summary)\n\n" - } - - if let keyPoints = task.keyPoints { - md += "## Key Points\n\(keyPoints)\n\n" - } - - if let actionItems = task.actionItems { - md += "## Action Items\n\(actionItems)\n\n" - } - - if let transcript = task.transcript { - md += "## Transcript\n\(transcript)\n" - } - - return md - } + // MARK: - Hydration & Persistence diff --git a/Sources/VoiceMemo/Views/ResultView.swift b/Sources/VoiceMemo/Views/ResultView.swift index 07fbcfe..9368772 100644 --- a/Sources/VoiceMemo/Views/ResultView.swift +++ b/Sources/VoiceMemo/Views/ResultView.swift @@ -135,9 +135,9 @@ struct ResultView: View { isSendingEmail = true emailStatus = "Sending..." - // Generate Markdown - let mdContent = generateMarkdown() - let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent("\(task.title).md") + let mdContent = task.markdownSummary() + let filename = task.safeFilename().appending(".md") + let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(filename) do { try mdContent.write(to: tempUrl, atomically: true, encoding: .utf8) @@ -151,16 +151,13 @@ struct ResultView: View { emailStatus = "Sent" } catch { emailStatus = "Failed" - // Show error in a more prominent way if needed, or just log - print("Failed to send email: \(error.localizedDescription)") + settings.log("Email failed: \(error.localizedDescription)") } - // Clean up try? FileManager.default.removeItem(at: tempUrl) isSendingEmail = false - // Reset status after a delay try? await Task.sleep(nanoseconds: 3 * 1_000_000_000) emailStatus = nil } @@ -168,75 +165,15 @@ struct ResultView: View { private func exportMarkdown() { let panel = NSSavePanel() panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText] - panel.nameFieldStringValue = "\(task.title).md" + panel.nameFieldStringValue = "\(task.safeFilename()).md" panel.begin { response in if response == .OK, let url = panel.url { - let content = generateMarkdown() + let content = task.markdownSummary() try? content.write(to: url, atomically: true, encoding: .utf8) } } } - - private func generateMarkdown() -> String { - var md = "# \(task.title)\n\n" - md += "Date: \(task.createdAt)\n\n" - - // Metadata - md += "## Task Info\n" - if let key = task.taskKey { md += "- Task Key: \(key)\n" } - if let status = task.apiStatus { md += "- Status: \(status)\n" } - if let error = task.statusText, !error.isEmpty { md += "- Message: \(error)\n" } - if let duration = task.bizDuration { md += "- Duration: \(duration / 1000)s\n" } - if let mp3 = task.outputMp3Path { md += "- Audio: [Download](\(mp3))\n" } - md += "\n" - - if let summary = task.summary { - md += "## Summary\n\(summary)\n\n" - } - - if let keyPoints = task.keyPoints { - md += "## Key Points\n\(keyPoints)\n\n" - } - - if let actionItems = task.actionItems { - md += "## Action Items\n\(actionItems)\n\n" - } - - if let transcript = derivedTranscript() { - md += "## Transcript\n\(transcript)\n" - } - - return md - } - - private func derivedTranscript() -> String? { - if let transcript = task.transcript, !transcript.isEmpty { - return transcript - } - - // Try to parse from transcriptData (full JSON from DB) - if let dataStr = task.transcriptData, - let data = dataStr.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - if let text = TranscriptParser.buildTranscriptText(from: json) { - return text - } - } - - // Fallback to parsing rawResponse using TranscriptParser - guard let raw = task.rawResponse, - let data = raw.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil - } - - return TranscriptParser.buildTranscriptText(from: json) - } - - // extractTranscript, extractText, extractSpeaker and stringify helper methods are no longer needed - // as all parsing logic is now centralized in TranscriptParser - // and derivedTranscript() delegates entirely to TranscriptParser. } diff --git a/Sources/VoiceMemo/Views/SettingsView.swift b/Sources/VoiceMemo/Views/SettingsView.swift index a497ffa..fe94779 100644 --- a/Sources/VoiceMemo/Views/SettingsView.swift +++ b/Sources/VoiceMemo/Views/SettingsView.swift @@ -2,10 +2,6 @@ import SwiftUI import AppKit struct SettingsView: View { - private enum SettingsCategory: String, CaseIterable { - case general, asr, oss, storage, logs, email - } - // MARK: - Properties @ObservedObject var settings: SettingsStore From 185e776ade1dbdd21e00634d2454f7177ecb5dbb Mon Sep 17 00:00:00 2001 From: Sherlock Yin Date: Sat, 28 Feb 2026 11:57:16 +0800 Subject: [PATCH 3/3] fix(storage): stabilize mysql provider and allow http gateway - avoid provider rebuild on app launch - debounce mysql config changes and observe password state - shut down mysql event loop asynchronously - fix transcript tab call and email token save - allow http gateway for configured host --- Sources/VoiceMemo/Info.plist | 7 ++ .../Services/Storage/MySQLStorage.swift | 9 +- .../Services/Storage/StorageManager.swift | 24 +++-- Sources/VoiceMemo/Views/ResultView.swift | 2 +- Sources/VoiceMemo/Views/SettingsView.swift | 92 +++++++++++-------- 5 files changed, 79 insertions(+), 55 deletions(-) diff --git a/Sources/VoiceMemo/Info.plist b/Sources/VoiceMemo/Info.plist index 3f28837..297cc01 100644 --- a/Sources/VoiceMemo/Info.plist +++ b/Sources/VoiceMemo/Info.plist @@ -39,6 +39,13 @@ NSIncludesSubdomains + 47.99.241.152 + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + diff --git a/Sources/VoiceMemo/Services/Storage/MySQLStorage.swift b/Sources/VoiceMemo/Services/Storage/MySQLStorage.swift index 7b50542..fd817fe 100644 --- a/Sources/VoiceMemo/Services/Storage/MySQLStorage.swift +++ b/Sources/VoiceMemo/Services/Storage/MySQLStorage.swift @@ -60,11 +60,10 @@ final class MySQLStorage: StorageProvider, @unchecked Sendable { return } isShutdown = true - if let pool = pool { - self.pool = nil - pool.shutdown() - } - try? group.syncShutdownGracefully() + let localPool = pool + self.pool = nil + localPool?.shutdown() + group.shutdownGracefully { _ in } } func createTableIfNeeded() async throws { diff --git a/Sources/VoiceMemo/Services/Storage/StorageManager.swift b/Sources/VoiceMemo/Services/Storage/StorageManager.swift index d813d83..0b3bb7e 100644 --- a/Sources/VoiceMemo/Services/Storage/StorageManager.swift +++ b/Sources/VoiceMemo/Services/Storage/StorageManager.swift @@ -29,17 +29,21 @@ class StorageManager: ObservableObject { } .store(in: &cancellables) - // Listen to MySQL config changes to update the provider - settings.objectWillChange - .sink { [weak self] _ in - // Debounce or just check if relevant fields changed? - // For simplicity, we can update on next access or lazily. - // But if we are currently using MySQL, we might need to reconnect. - if self?.settingsStore?.storageType == .mysql { - self?.updateMySQLProvider() - } + Publishers.CombineLatest4( + settings.$mysqlHost, + settings.$mysqlPort, + settings.$mysqlUser, + settings.$mysqlDatabase + ) + .combineLatest(settings.$hasMySQLPassword) + .dropFirst() + .debounce(for: .seconds(1.0), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + if self?.settingsStore?.storageType == .mysql { + self?.updateMySQLProvider() } - .store(in: &cancellables) + } + .store(in: &cancellables) // Initial setup switchProvider(to: settings.storageType) diff --git a/Sources/VoiceMemo/Views/ResultView.swift b/Sources/VoiceMemo/Views/ResultView.swift index 9368772..cf86192 100644 --- a/Sources/VoiceMemo/Views/ResultView.swift +++ b/Sources/VoiceMemo/Views/ResultView.swift @@ -115,7 +115,7 @@ struct ResultView: View { case .overview: OverviewView(task: task) case .transcript: - TranscriptView(text: derivedTranscript() ?? "No transcript available.") + TranscriptView(text: task.derivedTranscriptText() ?? "No transcript available.") case .raw: RawDataView(text: task.rawData ?? task.rawResponse ?? "No raw response.") case .pipeline: diff --git a/Sources/VoiceMemo/Views/SettingsView.swift b/Sources/VoiceMemo/Views/SettingsView.swift index fe94779..63a8260 100644 --- a/Sources/VoiceMemo/Views/SettingsView.swift +++ b/Sources/VoiceMemo/Views/SettingsView.swift @@ -602,54 +602,68 @@ struct SettingsView: View { } private var emailForm: some View { - VStack(spacing: Layout.standardSpacing) { - StyledGroupBox("FastMail Gateway") { - FormRow(label: "Enable") { - Toggle("Enable Email Notification", isOn: $settings.enableEmailNotification) + VStack(alignment: .leading, spacing: Layout.standardSpacing) { + StyledGroupBox("Email Configuration") { + VStack(alignment: .leading, spacing: Layout.groupSpacing) { + Toggle("Enable Email Notifications", isOn: $settings.enableEmailNotification) .toggleStyle(.switch) - .labelsHidden() - Spacer() - } - - if settings.enableEmailNotification { - FormRow(label: "Gateway URL") { - TextField("e.g. http://localhost:8080", text: $settings.fastmailUrl) - .textFieldStyle(.roundedBorder) - } - FormRow(label: "Recipient") { - TextField("Email address", text: $settings.recipientEmail) - .textFieldStyle(.roundedBorder) + if settings.enableEmailNotification { + Divider().padding(.vertical, 4) + + FormRow(label: "Gateway URL") { + TextField("https://your-fastmail-gateway.com", text: $settings.fastmailUrl) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + .help("The URL of your FastMail gateway instance") + } + + FormRow(label: "API Token") { + SecureField("Enter Gateway Token", text: $fastmailTokenInput) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + .onChange(of: fastmailTokenInput) { newValue in + if !newValue.isEmpty { + settings.saveFastmailToken(newValue) + } + } + } + + FormRow(label: "Recipient") { + TextField("your-email@example.com", text: $settings.recipientEmail) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + .help("Where the meeting summary will be sent") + } } - - FormRow(label: "Token") { - CredentialRow( - hasValue: settings.hasFastmailToken, - input: $fastmailTokenInput, - placeholder: "Bearer Token", - isSecure: true, - onSave: { settings.saveFastmailToken(fastmailTokenInput) }, - onClear: { settings.clearFastmailSecrets() } - ) + } + } + + if settings.enableEmailNotification { + Button(action: { + Task { + await testEmail() } - - Divider() - - FormRow(label: "Actions") { - HStack { - Button("Test Email") { - Task { await testEmail() } - } - if !emailTestStatus.isEmpty { - Text(emailTestStatus) - .font(.caption) - .foregroundColor(emailTestStatus.contains("Success") ? .green : .red) - } + }) { + HStack { + if emailTestStatus == "Testing..." { + ProgressView().controlSize(.small).padding(.trailing, 4) } + Text("Test Email Notification") } } + .disabled(emailTestStatus == "Testing...") + + if !emailTestStatus.isEmpty { + Text(emailTestStatus) + .font(.caption) + .foregroundColor(emailTestStatus.contains("Success") ? .green : .red) + } } } + .onAppear { + fastmailTokenInput = "" + } } private func testEmail() async {