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/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/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
new file mode 100644
index 0000000..100cb8e
--- /dev/null
+++ b/Sources/VoiceMemo/Services/EmailService.swift
@@ -0,0 +1,101 @@
+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 {
+ 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
+ }
+
+ 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()
+
+ func append(_ string: String) {
+ if let data = string.data(using: .utf8) {
+ httpBody.append(data)
+ }
+ }
+
+ 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")
+ }
+
+ appendField(name: "to", value: recipient)
+ appendField(name: "subject", value: subject)
+ appendField(name: "body", value: body)
+
+ 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
+
+ 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..9c118b1 100644
--- a/Sources/VoiceMemo/Services/MeetingPipelineManager.swift
+++ b/Sources/VoiceMemo/Services/MeetingPipelineManager.swift
@@ -116,15 +116,15 @@ 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)
await MainActor.run { self.isProcessing = true }
+ var isChainCompleted = true
+
for node in nodes {
- // Update UI status to "Running"
await updateStatus(node.step, isFailed: false)
var success = false
@@ -132,70 +132,97 @@ 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:
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 }
+
+ if isChainCompleted && settings.enableEmailNotification && task.status == .completed {
+ await sendEmailNotification()
+ }
+ }
+
+ private func sendEmailNotification() async {
+ await MainActor.run { self.isProcessing = true }
+
+ 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)
+
+ 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)"
+ }
+ }
+
+ try? FileManager.default.removeItem(at: tempUrl)
+
+ await MainActor.run { self.isProcessing = false }
}
+
+
// MARK: - Hydration & Persistence
private func createBoard(from task: MeetingTask) -> PipelineBoard {
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/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/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..cf86192 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")
}
@@ -96,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:
@@ -112,78 +131,49 @@ struct ResultView: View {
}
}
- private func exportMarkdown() {
- let panel = NSSavePanel()
- panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText]
- panel.nameFieldStringValue = "\(task.title).md"
-
- panel.begin { response in
- if response == .OK, let url = panel.url {
- let content = generateMarkdown()
- 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"
+ private func sendEmail() async {
+ isSendingEmail = true
+ emailStatus = "Sending..."
- if let summary = task.summary {
- md += "## Summary\n\(summary)\n\n"
- }
+ let mdContent = task.markdownSummary()
+ let filename = task.safeFilename().appending(".md")
+ let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
- if let keyPoints = task.keyPoints {
- md += "## Key Points\n\(keyPoints)\n\n"
+ 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"
+ settings.log("Email failed: \(error.localizedDescription)")
}
- if let actionItems = task.actionItems {
- md += "## Action Items\n\(actionItems)\n\n"
- }
+ try? FileManager.default.removeItem(at: tempUrl)
- if let transcript = derivedTranscript() {
- md += "## Transcript\n\(transcript)\n"
- }
+ isSendingEmail = false
- return md
+ try? await Task.sleep(nanoseconds: 3 * 1_000_000_000)
+ emailStatus = nil
}
- private func derivedTranscript() -> String? {
- if let transcript = task.transcript, !transcript.isEmpty {
- return transcript
- }
+ private func exportMarkdown() {
+ let panel = NSSavePanel()
+ panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText]
+ panel.nameFieldStringValue = "\(task.safeFilename()).md"
- // 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
+ panel.begin { response in
+ if response == .OK, let url = panel.url {
+ let content = task.markdownSummary()
+ try? content.write(to: url, atomically: true, encoding: .utf8)
}
}
-
- // 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 04b57bc..63a8260 100644
--- a/Sources/VoiceMemo/Views/SettingsView.swift
+++ b/Sources/VoiceMemo/Views/SettingsView.swift
@@ -2,6 +2,8 @@ import SwiftUI
import AppKit
struct SettingsView: View {
+ // MARK: - Properties
+
@ObservedObject var settings: SettingsStore
@ObservedObject var storageManager = StorageManager.shared
var category: SettingsCategory?
@@ -14,6 +16,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 +38,8 @@ struct SettingsView: View {
ossForm
case .storage:
storageForm
+ case .email:
+ emailForm
case .logs:
logsForm
}
@@ -43,6 +49,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 +601,86 @@ struct SettingsView: View {
}
}
+ private var emailForm: some View {
+ VStack(alignment: .leading, spacing: Layout.standardSpacing) {
+ StyledGroupBox("Email Configuration") {
+ VStack(alignment: .leading, spacing: Layout.groupSpacing) {
+ Toggle("Enable Email Notifications", isOn: $settings.enableEmailNotification)
+ .toggleStyle(.switch)
+
+ 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")
+ }
+ }
+ }
+ }
+
+ if settings.enableEmailNotification {
+ Button(action: {
+ Task {
+ await testEmail()
+ }
+ }) {
+ 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 {
+ 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)