From f844aa3adf6c850110a30fce6d4f1b08b3ab00f0 Mon Sep 17 00:00:00 2001 From: Sherlock Yin Date: Sat, 28 Feb 2026 17:13:16 +0800 Subject: [PATCH 1/4] feat(email): support multiple recipients --- Sources/VoiceMemo/Services/EmailService.swift | 17 ++++++-- Sources/VoiceMemo/Views/SettingsView.swift | 6 +-- Tests/VoiceMemoTests/EmailServiceTests.swift | 40 +++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 Tests/VoiceMemoTests/EmailServiceTests.swift diff --git a/Sources/VoiceMemo/Services/EmailService.swift b/Sources/VoiceMemo/Services/EmailService.swift index 100cb8e..7db5093 100644 --- a/Sources/VoiceMemo/Services/EmailService.swift +++ b/Sources/VoiceMemo/Services/EmailService.swift @@ -25,14 +25,25 @@ class EmailService { self.settings = settings } + // Helper for testing + static func parseRecipients(_ raw: String) -> String { + return raw.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: ",") + } + func sendEmail(subject: String, body: String, attachmentPath: String?) async throws { let gatewayUrlString = settings.fastmailUrl let token = settings.getFastmailToken() - let recipient = settings.recipientEmail + let rawRecipients = settings.recipientEmail + + // Clean up and validate recipients + let recipients = Self.parseRecipients(rawRecipients) guard !gatewayUrlString.isEmpty, let token = token, !token.isEmpty, - !recipient.isEmpty else { + !recipients.isEmpty else { throw EmailError.missingConfiguration } @@ -61,7 +72,7 @@ class EmailService { append("\(value)\r\n") } - appendField(name: "to", value: recipient) + appendField(name: "to", value: recipients) appendField(name: "subject", value: subject) appendField(name: "body", value: body) diff --git a/Sources/VoiceMemo/Views/SettingsView.swift b/Sources/VoiceMemo/Views/SettingsView.swift index 63a8260..54693db 100644 --- a/Sources/VoiceMemo/Views/SettingsView.swift +++ b/Sources/VoiceMemo/Views/SettingsView.swift @@ -629,11 +629,11 @@ struct SettingsView: View { } } - FormRow(label: "Recipient") { - TextField("your-email@example.com", text: $settings.recipientEmail) + FormRow(label: "Recipients") { + TextField("email1@example.com, email2@example.com", text: $settings.recipientEmail) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) - .help("Where the meeting summary will be sent") + .help("Where the meeting summary will be sent. Separate multiple emails with commas.") } } } diff --git a/Tests/VoiceMemoTests/EmailServiceTests.swift b/Tests/VoiceMemoTests/EmailServiceTests.swift new file mode 100644 index 0000000..ccd0e7f --- /dev/null +++ b/Tests/VoiceMemoTests/EmailServiceTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import VoiceMemo + +final class EmailServiceTests: XCTestCase { + + func testParseRecipients_SingleEmail() { + let input = "test@example.com" + let expected = "test@example.com" + let result = EmailService.parseRecipients(input) + XCTAssertEqual(result, expected) + } + + func testParseRecipients_MultipleEmails() { + let input = "a@b.com,c@d.com" + let expected = "a@b.com,c@d.com" + let result = EmailService.parseRecipients(input) + XCTAssertEqual(result, expected) + } + + func testParseRecipients_WithSpaces() { + let input = " a@b.com , c@d.com " + let expected = "a@b.com,c@d.com" + let result = EmailService.parseRecipients(input) + XCTAssertEqual(result, expected) + } + + func testParseRecipients_EmptyParts() { + let input = "a@b.com,,c@d.com," + let expected = "a@b.com,c@d.com" + let result = EmailService.parseRecipients(input) + XCTAssertEqual(result, expected) + } + + func testParseRecipients_OnlySpaces() { + let input = " " + let expected = "" + let result = EmailService.parseRecipients(input) + XCTAssertEqual(result, expected) + } +} From d1a649d92a9279b9e44553610aefe5da27dd6800 Mon Sep 17 00:00:00 2001 From: Sherlock Yin Date: Sat, 28 Feb 2026 17:14:30 +0800 Subject: [PATCH 2/4] docs(email): update documentation for multi-recipient support --- doc/10-email-notification.md | 4 ++-- doc/10-email-notification.zh-CN.md | 4 ++-- openspec/specs/email-notification/spec.md | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/doc/10-email-notification.md b/doc/10-email-notification.md index 804aea8..95e6d4f 100644 --- a/doc/10-email-notification.md +++ b/doc/10-email-notification.md @@ -4,14 +4,14 @@ 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. +The system will automatically generate a Markdown summary of the meeting and send it via email to configured recipients (supports multiple recipients) 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. +- **Recipient Emails**: The email addresses where the summary should be sent. Separate multiple emails with commas. ## Workflow diff --git a/doc/10-email-notification.zh-CN.md b/doc/10-email-notification.zh-CN.md index c564f47..424686a 100644 --- a/doc/10-email-notification.zh-CN.md +++ b/doc/10-email-notification.zh-CN.md @@ -4,14 +4,14 @@ 本文档概述了使用 [FastMail Gateway](https://github.com/mistbit/fastmail) 集成自动邮件通知的功能。 ## 功能描述 -当处理流水线成功完成后,系统将自动生成会议的 Markdown 摘要,并通过邮件发送给配置的收件人。 +当处理流水线成功完成后,系统将自动生成会议的 Markdown 摘要,并通过邮件发送给配置的收件人(支持多个收件人)。 ## 配置 用户可以在应用设置中配置邮件网关: - **网关 URL (Gateway URL)**:部署的 FastMail 服务端点(例如 `http://localhost:8080`)。 - **认证令牌 (Authentication Token)**:用于访问网关的安全令牌。 -- **收件人邮箱 (Recipient Email)**:接收摘要的邮箱地址。 +- **收件人邮箱 (Recipient Emails)**:接收摘要的邮箱地址。支持多个邮箱,使用逗号分隔。 ## 工作流程 diff --git a/openspec/specs/email-notification/spec.md b/openspec/specs/email-notification/spec.md index bb5802a..390bb58 100644 --- a/openspec/specs/email-notification/spec.md +++ b/openspec/specs/email-notification/spec.md @@ -16,7 +16,7 @@ The system SHALL support configuration for the `fastmail` gateway. - **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) + - `Recipient Emails` (comma-separated email addresses) - **AND** these settings MUST be persisted securely (Token in Keychain). ### Requirement: Automated Email Sending @@ -24,13 +24,13 @@ The system SHALL automatically send an email with the meeting summary upon succe #### 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 +- **AND** the `FastMail Gateway URL` and `Recipient Emails` 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`: `` + - `to`: `` (comma-separated) - `subject`: `Meeting Summary: ` - `body`: "Please find the attached meeting summary." (or the summary content itself if preferred) - `attachments`: The generated Markdown file From 41aa1139dc79421caa11ebe0f236b37c17c471f0 Mon Sep 17 00:00:00 2001 From: Sherlock Yin Date: Sat, 28 Feb 2026 23:06:56 +0800 Subject: [PATCH 3/4] feat(email): support multiple attachments (summary, audio, transcript, raw data) - Add toggles in SettingsView for different attachment types - Update EmailService to handle multiple attachment paths - Implement attachment preparation logic in ResultView including audio download - Add new settings to SettingsStore for email attachment preferences --- Sources/VoiceMemo/Services/EmailService.swift | 72 ++++++++++----- .../VoiceMemo/Services/SettingsStore.swift | 20 +++++ Sources/VoiceMemo/Views/ResultView.swift | 90 ++++++++++++++++--- Sources/VoiceMemo/Views/SettingsView.swift | 56 ++++++++++-- 4 files changed, 197 insertions(+), 41 deletions(-) diff --git a/Sources/VoiceMemo/Services/EmailService.swift b/Sources/VoiceMemo/Services/EmailService.swift index 7db5093..a1c5ec1 100644 --- a/Sources/VoiceMemo/Services/EmailService.swift +++ b/Sources/VoiceMemo/Services/EmailService.swift @@ -33,54 +33,51 @@ class EmailService { .joined(separator: ",") } - func sendEmail(subject: String, body: String, attachmentPath: String?) async throws { + func sendEmail(subject: String, body: String, attachmentPaths: [String]?) async throws { let gatewayUrlString = settings.fastmailUrl let token = settings.getFastmailToken() let rawRecipients = settings.recipientEmail - + // Clean up and validate recipients let recipients = Self.parseRecipients(rawRecipients) - + guard !gatewayUrlString.isEmpty, let token = token, !token.isEmpty, !recipients.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: recipients) - appendField(name: "subject", value: subject) - appendField(name: "body", value: body) - - if let attachmentPath = attachmentPath, FileManager.default.fileExists(atPath: attachmentPath) { - let fileUrl = URL(fileURLWithPath: attachmentPath) + + func appendFile(path: String) { + let fileUrl = URL(fileURLWithPath: path) let filename = fileUrl.lastPathComponent - let mimeType = "text/markdown" - + let fileExtension = fileUrl.pathExtension.lowercased() + let mimeType = mimeTypeForExtension(fileExtension) + if let fileData = try? Data(contentsOf: fileUrl) { append("--\(boundary)\r\n") append("Content-Disposition: form-data; name=\"attachments\"; filename=\"\(filename)\"\r\n") @@ -89,17 +86,30 @@ class EmailService { append("\r\n") } } - + + appendField(name: "to", value: recipients) + appendField(name: "subject", value: subject) + appendField(name: "body", value: body) + + // Attach files + if let attachmentPaths = attachmentPaths { + for path in attachmentPaths { + if FileManager.default.fileExists(atPath: path) { + appendFile(path: path) + } + } + } + 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) } @@ -109,4 +119,22 @@ class EmailService { throw EmailError.networkError(error) } } + + // Helper method for backward compatibility + func sendEmail(subject: String, body: String, attachmentPath: String?) async throws { + let paths = attachmentPath != nil ? [attachmentPath!] : nil + try await sendEmail(subject: subject, body: body, attachmentPaths: paths) + } + + private func mimeTypeForExtension(_ fileExtension: String) -> String { + switch fileExtension { + case "md", "markdown": return "text/markdown" + case "txt": return "text/plain" + case "json": return "application/json" + case "mp3": return "audio/mpeg" + case "m4a": return "audio/mp4" + case "wav": return "audio/wav" + default: return "application/octet-stream" + } + } } diff --git a/Sources/VoiceMemo/Services/SettingsStore.swift b/Sources/VoiceMemo/Services/SettingsStore.swift index b3f73aa..8a59e53 100644 --- a/Sources/VoiceMemo/Services/SettingsStore.swift +++ b/Sources/VoiceMemo/Services/SettingsStore.swift @@ -123,6 +123,20 @@ class SettingsStore: ObservableObject { didSet { UserDefaults.standard.set(enableEmailNotification, forKey: "enableEmailNotification") } } @Published var hasFastmailToken: Bool = false + + // Email Attachment Options + @Published var emailAttachSummary: Bool { + didSet { UserDefaults.standard.set(emailAttachSummary, forKey: "emailAttachSummary") } + } + @Published var emailAttachAudio: Bool { + didSet { UserDefaults.standard.set(emailAttachAudio, forKey: "emailAttachAudio") } + } + @Published var emailAttachTranscript: Bool { + didSet { UserDefaults.standard.set(emailAttachTranscript, forKey: "emailAttachTranscript") } + } + @Published var emailAttachRawData: Bool { + didSet { UserDefaults.standard.set(emailAttachRawData, forKey: "emailAttachRawData") } + } // Audio Recording Config @Published var savePathBookmark: Data? { @@ -184,6 +198,12 @@ class SettingsStore: ObservableObject { self.fastmailUrl = UserDefaults.standard.string(forKey: "fastmailUrl") ?? "" self.recipientEmail = UserDefaults.standard.string(forKey: "recipientEmail") ?? "" self.enableEmailNotification = UserDefaults.standard.object(forKey: "enableEmailNotification") as? Bool ?? false + + // Email Attachment Options + self.emailAttachSummary = UserDefaults.standard.object(forKey: "emailAttachSummary") as? Bool ?? true + self.emailAttachAudio = UserDefaults.standard.object(forKey: "emailAttachAudio") as? Bool ?? false + self.emailAttachTranscript = UserDefaults.standard.object(forKey: "emailAttachTranscript") as? Bool ?? false + self.emailAttachRawData = UserDefaults.standard.object(forKey: "emailAttachRawData") as? Bool ?? false migrateLegacySecrets() checkSecrets() diff --git a/Sources/VoiceMemo/Views/ResultView.swift b/Sources/VoiceMemo/Views/ResultView.swift index 1c0490b..0b8507a 100644 --- a/Sources/VoiceMemo/Views/ResultView.swift +++ b/Sources/VoiceMemo/Views/ResultView.swift @@ -143,33 +143,97 @@ struct ResultView: View { private func sendEmail() async { isSendingEmail = true emailStatus = "Sending..." - - let mdContent = task.markdownSummary() - let filename = task.safeFilename().appending(".md") - let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(filename) - + + var attachmentPaths: [String] = [] + var tempFiles: [URL] = [] + let baseFilename = task.safeFilename() + + // Prepare summary attachment + if settings.emailAttachSummary { + let mdContent = task.markdownSummary() + let mdFilename = baseFilename.appending(".md") + let mdUrl = FileManager.default.temporaryDirectory.appendingPathComponent(mdFilename) + if let mdData = mdContent.data(using: .utf8) { + try? mdData.write(to: mdUrl) + tempFiles.append(mdUrl) + attachmentPaths.append(mdUrl.path) + } + } + + // Prepare audio attachment + if settings.emailAttachAudio { + // Check for local file first + var audioPath: String? + if FileManager.default.fileExists(atPath: task.localFilePath) { + audioPath = task.localFilePath + } else if let mp3UrlString = task.outputMp3Path, let mp3Url = URL(string: mp3UrlString) { + // Try to download from URL + if let tempMp3Url = try? await downloadFile(from: mp3Url) { + tempFiles.append(tempMp3Url) + audioPath = tempMp3Url.path + } + } + + if let path = audioPath { + attachmentPaths.append(path) + } + } + + // Prepare transcript attachment + if settings.emailAttachTranscript, let transcriptText = task.derivedTranscriptText() { + let transcriptFilename = baseFilename.appending("-transcript.txt") + let transcriptUrl = FileManager.default.temporaryDirectory.appendingPathComponent(transcriptFilename) + if let transcriptData = transcriptText.data(using: .utf8) { + try? transcriptData.write(to: transcriptUrl) + tempFiles.append(transcriptUrl) + attachmentPaths.append(transcriptUrl.path) + } + } + + // Prepare raw data attachment + if settings.emailAttachRawData { + if let rawDataStr = task.rawData ?? task.rawResponse { + let rawFilename = baseFilename.appending("-raw.json") + let rawUrl = FileManager.default.temporaryDirectory.appendingPathComponent(rawFilename) + if let rawData = rawDataStr.data(using: .utf8) { + try? rawData.write(to: rawUrl) + tempFiles.append(rawUrl) + attachmentPaths.append(rawUrl.path) + } + } + } + do { - try mdContent.write(to: tempUrl, atomically: true, encoding: .utf8) - let emailService = EmailService(settings: settings) + let paths = attachmentPaths.isEmpty ? nil : attachmentPaths try await emailService.sendEmail( subject: "Meeting Summary: \(task.title)", - body: "Please find the attached meeting summary.", - attachmentPath: tempUrl.path + body: "Please find the attached meeting content.", + attachmentPaths: paths ) emailStatus = "Sent" } catch { emailStatus = "Failed" settings.log("Email failed: \(error.localizedDescription)") } - - try? FileManager.default.removeItem(at: tempUrl) - + + // Clean up temporary files + for tempFile in tempFiles { + try? FileManager.default.removeItem(at: tempFile) + } + isSendingEmail = false - + try? await Task.sleep(nanoseconds: 3 * 1_000_000_000) emailStatus = nil } + + private func downloadFile(from url: URL) async throws -> URL { + let (tempUrl, _) = try await URLSession.shared.download(from: url) + let destination = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent) + try FileManager.default.moveItem(at: tempUrl, to: destination) + return destination + } private func exportMarkdown() { let panel = NSSavePanel() diff --git a/Sources/VoiceMemo/Views/SettingsView.swift b/Sources/VoiceMemo/Views/SettingsView.swift index 54693db..ce15528 100644 --- a/Sources/VoiceMemo/Views/SettingsView.swift +++ b/Sources/VoiceMemo/Views/SettingsView.swift @@ -607,17 +607,17 @@ struct SettingsView: View { 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) @@ -628,7 +628,7 @@ struct SettingsView: View { } } } - + FormRow(label: "Recipients") { TextField("email1@example.com, email2@example.com", text: $settings.recipientEmail) .textFieldStyle(.roundedBorder) @@ -638,8 +638,52 @@ struct SettingsView: View { } } } - + if settings.enableEmailNotification { + StyledGroupBox("Email Attachments") { + VStack(alignment: .leading, spacing: 12) { + Text("Select which content to include in email attachments:") + .font(.caption) + .foregroundColor(.secondary) + + FormRow(label: "Summary") { + Toggle("", isOn: $settings.emailAttachSummary) + .toggleStyle(.switch) + .labelsHidden() + Text("Include markdown summary") + .font(.caption) + .foregroundColor(.secondary) + } + + FormRow(label: "Audio") { + Toggle("", isOn: $settings.emailAttachAudio) + .toggleStyle(.switch) + .labelsHidden() + Text("Include audio recording file") + .font(.caption) + .foregroundColor(.secondary) + } + + FormRow(label: "Transcript") { + Toggle("", isOn: $settings.emailAttachTranscript) + .toggleStyle(.switch) + .labelsHidden() + Text("Include transcript text file") + .font(.caption) + .foregroundColor(.secondary) + } + + FormRow(label: "Raw Data") { + Toggle("", isOn: $settings.emailAttachRawData) + .toggleStyle(.switch) + .labelsHidden() + Text("Include raw JSON response") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + Button(action: { Task { await testEmail() @@ -653,7 +697,7 @@ struct SettingsView: View { } } .disabled(emailTestStatus == "Testing...") - + if !emailTestStatus.isEmpty { Text(emailTestStatus) .font(.caption) From 0ee046e2926a7f888cda4847e6cb55c5f411e66a Mon Sep 17 00:00:00 2001 From: Sherlock Yin Date: Sat, 28 Feb 2026 23:11:30 +0800 Subject: [PATCH 4/4] style(settings): optimize recipient UI and fix email service calls - Add recipient tag preview and clear button in SettingsView - Fix sendEmail call in testEmail and MeetingPipelineManager - Update documentation to reflect multiple attachments support --- .../Services/MeetingPipelineManager.swift | 69 ++++++++++++++++--- Sources/VoiceMemo/Views/SettingsView.swift | 63 +++++++++++++++-- doc/10-email-notification.md | 17 +++-- doc/10-email-notification.zh-CN.md | 15 ++-- 4 files changed, 133 insertions(+), 31 deletions(-) diff --git a/Sources/VoiceMemo/Services/MeetingPipelineManager.swift b/Sources/VoiceMemo/Services/MeetingPipelineManager.swift index 9c118b1..22d6d30 100644 --- a/Sources/VoiceMemo/Services/MeetingPipelineManager.swift +++ b/Sources/VoiceMemo/Services/MeetingPipelineManager.swift @@ -195,18 +195,68 @@ class MeetingPipelineManager: ObservableObject { 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) + var attachmentPaths: [String] = [] + var tempFiles: [URL] = [] + let baseFilename = task.safeFilename() + + // Prepare summary attachment + if settings.emailAttachSummary { + let mdContent = task.markdownSummary() + let mdFilename = baseFilename.appending(".md") + let mdUrl = FileManager.default.temporaryDirectory.appendingPathComponent(mdFilename) + if let mdData = mdContent.data(using: .utf8) { + try? mdData.write(to: mdUrl) + tempFiles.append(mdUrl) + attachmentPaths.append(mdUrl.path) + } + } + + // Prepare audio attachment + if settings.emailAttachAudio { + if FileManager.default.fileExists(atPath: task.localFilePath) { + attachmentPaths.append(task.localFilePath) + } else if let mp3UrlString = task.outputMp3Path, let mp3Url = URL(string: mp3UrlString) { + // Try to download from URL + if let (tempUrl, _) = try? await URLSession.shared.download(from: mp3Url) { + let destination = FileManager.default.temporaryDirectory.appendingPathComponent(mp3Url.lastPathComponent) + try? FileManager.default.moveItem(at: tempUrl, to: destination) + tempFiles.append(destination) + attachmentPaths.append(destination.path) + } + } + } + + // Prepare transcript attachment + if settings.emailAttachTranscript, let transcriptText = task.derivedTranscriptText() { + let transcriptFilename = baseFilename.appending("-transcript.txt") + let transcriptUrl = FileManager.default.temporaryDirectory.appendingPathComponent(transcriptFilename) + if let transcriptData = transcriptText.data(using: .utf8) { + try? transcriptData.write(to: transcriptUrl) + tempFiles.append(transcriptUrl) + attachmentPaths.append(transcriptUrl.path) + } + } + + // Prepare raw data attachment + if settings.emailAttachRawData { + if let rawDataStr = task.rawData ?? task.rawResponse { + let rawFilename = baseFilename.appending("-raw.json") + let rawUrl = FileManager.default.temporaryDirectory.appendingPathComponent(rawFilename) + if let rawData = rawDataStr.data(using: .utf8) { + try? rawData.write(to: rawUrl) + tempFiles.append(rawUrl) + attachmentPaths.append(rawUrl.path) + } + } + } do { - try mdContent.write(to: tempUrl, atomically: true, encoding: .utf8) - let emailService = EmailService(settings: settings) + let paths = attachmentPaths.isEmpty ? nil : attachmentPaths try await emailService.sendEmail( subject: "Meeting Summary: \(task.title)", - body: "Please find the attached meeting summary.", - attachmentPath: tempUrl.path + body: "Please find the attached meeting content.", + attachmentPaths: paths ) settings.log("Email sent successfully to \(settings.recipientEmail)") } catch { @@ -216,7 +266,10 @@ class MeetingPipelineManager: ObservableObject { } } - try? FileManager.default.removeItem(at: tempUrl) + // Clean up temporary files + for tempFile in tempFiles { + try? FileManager.default.removeItem(at: tempFile) + } await MainActor.run { self.isProcessing = false } } diff --git a/Sources/VoiceMemo/Views/SettingsView.swift b/Sources/VoiceMemo/Views/SettingsView.swift index ce15528..0290d32 100644 --- a/Sources/VoiceMemo/Views/SettingsView.swift +++ b/Sources/VoiceMemo/Views/SettingsView.swift @@ -630,10 +630,59 @@ struct SettingsView: View { } FormRow(label: "Recipients") { - TextField("email1@example.com, email2@example.com", text: $settings.recipientEmail) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - .help("Where the meeting summary will be sent. Separate multiple emails with commas.") + VStack(alignment: .leading, spacing: 8) { + TextField("email1@example.com, email2@example.com", text: $settings.recipientEmail) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + + let recipients = EmailService.parseRecipients(settings.recipientEmail) + .split(separator: ",") + .map(String.init) + + if !recipients.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(recipients, id: \.self) { email in + HStack(spacing: 4) { + Image(systemName: "envelope.fill") + .font(.system(size: 10)) + Text(email) + .font(.system(size: 11, weight: .medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.accentColor.opacity(0.2), lineWidth: 1) + ) + } + } + .padding(.vertical, 2) + } + + HStack { + Text("Detected \(recipients.count) recipient(s).") + .font(.caption2) + .foregroundColor(.secondary) + + Spacer() + + Button("Clear All") { + settings.recipientEmail = "" + } + .buttonStyle(.borderless) + .font(.caption2) + .foregroundColor(.red) + } + } else { + Text("Separate multiple emails with commas.") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .help("Where the meeting summary will be sent. Separate multiple emails with commas.") } } } @@ -713,13 +762,15 @@ struct SettingsView: View { private func testEmail() async { emailTestStatus = "Testing..." let service = EmailService(settings: settings) + let recipients = EmailService.parseRecipients(settings.recipientEmail) + do { try await service.sendEmail( subject: "Test Email from VoiceMemo", body: "This is a test email to verify your configuration.", - attachmentPath: nil + attachmentPaths: nil ) - emailTestStatus = "Success: Email sent" + emailTestStatus = "Success: Email sent to \(recipients)" } catch { emailTestStatus = "Failed: \(error.localizedDescription)" } diff --git a/doc/10-email-notification.md b/doc/10-email-notification.md index 95e6d4f..49b288e 100644 --- a/doc/10-email-notification.md +++ b/doc/10-email-notification.md @@ -19,18 +19,17 @@ Users can configure the email gateway in the application settings: - 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 +2. **Attachment Preparation**: + - The system prepares attachments based on user settings: + - **Summary**: Markdown file with metadata, summary, key points, and action items. + - **Audio**: Original audio recording file (local or downloaded). + - **Transcript**: Full transcript text file. + - **Raw Data**: Raw JSON response from the ASR provider. 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. + - The request includes the selected files as attachments. + - The email is sent to the configured recipients. 4. **Status Feedback**: - The pipeline status reflects the outcome of the email sending process (Success/Failure). diff --git a/doc/10-email-notification.zh-CN.md b/doc/10-email-notification.zh-CN.md index 424686a..ac5aa00 100644 --- a/doc/10-email-notification.zh-CN.md +++ b/doc/10-email-notification.zh-CN.md @@ -19,17 +19,16 @@ - `MeetingPipelineManager` 检测到转写和摘要任务已完成。 - 如果启用了邮件通知,系统将开始生成 Markdown 文件。 -2. **Markdown 生成**: - - 系统生成包含以下内容的 Markdown 文件: - - 会议元数据(标题、日期、时长) - - 摘要 - - 关键点 - - 待办事项 - - 完整转写文本 +2. **附件准备**: + - 系统根据用户设置准备附件: + - **摘要**:包含元数据、摘要、关键点和待办事项的 Markdown 文件。 + - **音频**:原始录音文件(本地或下载)。 + - **转录**:完整转录文本文件。 + - **原始数据**:来自 ASR 提供商的原始 JSON 响应。 3. **邮件分发**: - 系统构造一个 multipart HTTP POST 请求到配置的网关 URL。 - - 请求包含作为附件的 Markdown 文件。 + - 请求包含选定的文件作为附件。 - 邮件将被发送到配置的收件人。 4. **状态反馈**: