Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 49 additions & 22 deletions Sources/VoiceMemo/Services/EmailService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
}
Expand All @@ -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"
}
}
}
69 changes: 61 additions & 8 deletions Sources/VoiceMemo/Services/MeetingPipelineManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 }
}
Expand Down
20 changes: 20 additions & 0 deletions Sources/VoiceMemo/Services/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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()
Expand Down
90 changes: 77 additions & 13 deletions Sources/VoiceMemo/Views/ResultView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading