diff --git a/Sources/VoiceMemo/Services/EmailService.swift b/Sources/VoiceMemo/Services/EmailService.swift index 7db5093..b21bb2c 100644 --- a/Sources/VoiceMemo/Services/EmailService.swift +++ b/Sources/VoiceMemo/Services/EmailService.swift @@ -33,54 +33,50 @@ 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 +85,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 +118,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/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/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..0290d32 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,18 +628,111 @@ 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.") } } } } - + 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 +746,7 @@ struct SettingsView: View { } } .disabled(emailTestStatus == "Testing...") - + if !emailTestStatus.isEmpty { Text(emailTestStatus) .font(.caption) @@ -669,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. **状态反馈**: