Skip to content
Closed
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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,19 @@ make tauri-dev # Tauri development
- **MessageHandler.js** - Email state management (collection, pinning, sorting, selection)
- **FileHandler.js** - Drag-drop handling, delegates parsing to injected parsers
- **utils.js** - MSG/EML parsing logic using `@kenjiuno/msgreader` and custom MIME parser
- **tauri-bridge.js** - IPC layer for native features (file ops, dialogs, system viewer)
- **tauri-bridge.js** - IPC layer for native features (file ops, Save As dialog, updates)

### UI Layer (src/js/ui/)

- **UIManager.js** - Delegates to specialized sub-managers
- **MessageListRenderer.js** - Sidebar list with virtual scrolling
- **MessageContentRenderer.js** - Email body with HTML sanitization and inline images
- **AttachmentModalManager.js** - Attachment preview/download with system viewer support
- **AttachmentModalManager.js** - Attachment preview modal and download (web + Tauri Save As)
- **VirtualList.js** - Performance optimization for large email lists

### Desktop App (src-tauri/)

- **lib.rs** - Tauri commands: file reading, system viewer, Save As dialog
- **lib.rs** - Tauri commands: file reading, Save As dialog
- Single-instance enforcement, file associations (.msg, .eml), auto-update

## Key Patterns
Expand Down
57 changes: 1 addition & 56 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,61 +13,6 @@ fn read_file_as_bytes(path: String) -> Result<Vec<u8>, String> {
std::fs::read(&path).map_err(|e| format!("Failed to read file {}: {}", path, e))
}

/// Save a base64-encoded file to temp directory and open with system viewer
#[tauri::command]
fn open_file_with_system(base64_content: String, file_name: String) -> Result<(), String> {
use base64::{Engine as _, engine::general_purpose::STANDARD};

// Decode base64 content
let bytes = STANDARD.decode(&base64_content)
.map_err(|e| format!("Failed to decode base64: {}", e))?;

// Create temp file path
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(&file_name);

// Write to temp file
let mut file = std::fs::File::create(&temp_path)
.map_err(|e| format!("Failed to create temp file: {}", e))?;
file.write_all(&bytes)
.map_err(|e| format!("Failed to write temp file: {}", e))?;

// Open with system default application
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&temp_path)
.spawn()
.map_err(|e| format!("Failed to open file: {}", e))?;
}

#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;

// Use cmd.exe's start command - more reliable than PowerShell for opening files
// The empty "" is required as start interprets the first quoted arg as window title
// CREATE_NO_WINDOW prevents the console window from flashing
std::process::Command::new("cmd")
.args(["/c", "start", ""])
.arg(&temp_path)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("Failed to open file: {}", e))?;
}

#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&temp_path)
.spawn()
.map_err(|e| format!("Failed to open file: {}", e))?;
}

Ok(())
}

/// Save a file with a "Save As" dialog
#[tauri::command]
async fn save_file_with_dialog(
Expand Down Expand Up @@ -182,7 +127,7 @@ pub fn run() {

Ok(())
})
.invoke_handler(tauri::generate_handler![read_file_as_bytes, get_pending_files, open_file_with_system, save_file_with_dialog]);
.invoke_handler(tauri::generate_handler![read_file_as_bytes, get_pending_files, save_file_with_dialog]);

builder
.build(tauri::generate_context!())
Expand Down
24 changes: 0 additions & 24 deletions src/js/tauri-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,30 +89,6 @@ export function getFileName(filePath) {
return parts[parts.length - 1];
}

/**
* Open a file with the system's default application (Tauri only)
* Saves the file to a temp location and opens it
* @param {string} base64Data - Base64 data URL (data:mime/type;base64,...)
* @param {string} fileName - Original filename
* @returns {Promise<void>}
*/
export async function openWithSystemViewer(base64Data, fileName) {
if (!isTauri()) {
throw new Error('openWithSystemViewer is only available in Tauri');
}

const { invoke } = await import('@tauri-apps/api/core');

// Extract the base64 content (remove data:mime/type;base64, prefix)
const base64Content = base64Data.split(',')[1];

// Call Rust command to save and open the file
await invoke('open_file_with_system', {
base64Content,
fileName,
});
}

/**
* Save a file with a "Save As" dialog (Tauri only)
* @param {string} base64Data - Base64 data URL (data:mime/type;base64,...)
Expand Down
113 changes: 65 additions & 48 deletions src/js/ui/AttachmentModalManager.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isTauri, openWithSystemViewer, saveFileWithDialog } from '../tauri-bridge.js';
import { isTauri, saveFileWithDialog } from '../tauri-bridge.js';
import { extractEml } from '../utils.js';

/**
Expand Down Expand Up @@ -41,6 +41,7 @@ export class AttachmentModalManager {
this.imageViewerFallbackWidth = 960;
this.imageViewerFallbackHeight = 720;
this.inlineImageMetadataBySource = new Map();
this._pdfPreviewObjectUrl = null;

// Navigation stack for nested content (e.g., attachments within nested emails)
this.navigationStack = [];
Expand Down Expand Up @@ -288,13 +289,34 @@ export class AttachmentModalManager {
}

/**
* Checks if a MIME type is PDF
* True when filename ends with .pdf (case-insensitive)
* @param {string} fileName
* @returns {boolean}
*/
_hasPdfExtension(fileName) {
return /\.pdf$/i.test(fileName || '');
}

/**
* MIME types often used when the real type is unknown but file is binary
* @param {string} mimeType
* @returns {boolean}
*/
_isGenericBinaryMime(mimeType) {
const m = (mimeType || '').toLowerCase();
return !m || m === 'application/octet-stream' || m === 'binary/octet-stream';
}

/**
* Checks if an attachment is PDF by MIME and/or .pdf extension with generic MIME
* @param {string} mimeType - MIME type to check
* @returns {boolean} True if the MIME type is PDF
* @param {string} [fileName=''] - Original filename
* @returns {boolean} True if the attachment should be previewed as PDF
*/
isPdf(mimeType) {
if (!mimeType) return false;
return mimeType.toLowerCase() === 'application/pdf';
isPdf(mimeType, fileName = '') {
const m = (mimeType || '').toLowerCase();
if (m === 'application/pdf') return true;
return this._hasPdfExtension(fileName) && this._isGenericBinaryMime(mimeType);
}

/**
Expand Down Expand Up @@ -326,38 +348,15 @@ export class AttachmentModalManager {
/**
* Checks if an attachment can be previewed in the modal
* @param {string} mimeType - MIME type to check
* @param {string} [fileName=''] - Original filename (used for PDF-by-extension)
* @returns {boolean} True if the attachment is previewable
*/
isPreviewable(mimeType) {
return this.isPreviewableImage(mimeType) || this.isPdf(mimeType) || this.isText(mimeType) || this.isPreviewableEml(mimeType);
}

/**
* Checks if an attachment should be opened with system viewer (Tauri PDF)
* @param {Object} attachment - Attachment object
* @returns {boolean} True if should use system viewer
*/
_shouldOpenWithSystemViewer(attachment) {
return isTauri() && this.isPdf(attachment.attachMimeTag);
}

/**
* Opens a PDF with the system viewer (Tauri only)
* @param {Object} attachment - Attachment object
*/
_openPdfWithSystemViewer(attachment) {
openWithSystemViewer(attachment.contentBase64, attachment.fileName)
.catch(err => {
console.error('Failed to open PDF with system viewer:', err);
if (this.showToast) {
this.showToast('Failed to open PDF', 'error');
}
});
isPreviewable(mimeType, fileName = '') {
return this.isPreviewableImage(mimeType) || this.isPdf(mimeType, fileName) || this.isText(mimeType) || this.isPreviewableEml(mimeType);
}

/**
* Opens the attachment preview modal for a specific attachment
* In Tauri, PDFs are opened with the system viewer instead of the modal
* @param {Object} attachment - Attachment object to preview
*/
open(attachment) {
Expand All @@ -366,16 +365,10 @@ export class AttachmentModalManager {
// Clear navigation stack when opening a new attachment
this.clearNavigationStack();

// In Tauri, open PDFs with system viewer (WebKit has issues with data: URLs)
if (this._shouldOpenWithSystemViewer(attachment)) {
this._openPdfWithSystemViewer(attachment);
return;
}

// Build list of previewable attachments if not already set
if (this.currentAttachments) {
this.previewableAttachments = this.currentAttachments.filter(att =>
this.isPreviewable(att.attachMimeTag)
this.isPreviewable(att.attachMimeTag, att.fileName)
);
}

Expand Down Expand Up @@ -405,6 +398,7 @@ export class AttachmentModalManager {
*/
renderAttachmentPreview(attachment) {
this.resetImagePreviewState();
this._revokePdfPreviewObjectUrl();

// Set filename with breadcrumb if navigating from nested content
this.updateFilenameWithBreadcrumb(attachment.fileName);
Expand All @@ -421,12 +415,27 @@ export class AttachmentModalManager {
// Render appropriate preview
if (this.isPreviewableImage(attachment.attachMimeTag)) {
this.renderImagePreview(attachment);
} else if (this.isPdf(attachment.attachMimeTag)) {
// Use object tag for better PDF compatibility with data: URLs
} else if (this.isPdf(attachment.attachMimeTag, attachment.fileName)) {
const pdfObject = document.createElement('object');
pdfObject.data = attachment.contentBase64;
pdfObject.type = 'application/pdf';
pdfObject.innerHTML = `<p class="text-center p-4">PDF cannot be displayed. <a href="${attachment.contentBase64}" download="${attachment.fileName}" class="text-blue-500 underline">Download here</a></p>`;
try {
const base64Data = attachment.contentBase64?.split(',')[1];
if (!base64Data) {
throw new Error('Invalid base64 data format');
}
const binary = atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'application/pdf' });
this._pdfPreviewObjectUrl = URL.createObjectURL(blob);
pdfObject.data = this._pdfPreviewObjectUrl;
} catch (err) {
console.error('Error building PDF preview:', err);
pdfObject.removeAttribute('data');
}
pdfObject.innerHTML = `<p class="text-center p-4">PDF cannot be displayed. <a href="${attachment.contentBase64}" download="${this.escapeHtml(attachment.fileName)}" class="text-blue-500 underline">Download here</a></p>`;
this.attachmentModalContent.appendChild(pdfObject);
} else if (this.isText(attachment.attachMimeTag)) {
// Decode base64 to text
Expand Down Expand Up @@ -625,6 +634,16 @@ export class AttachmentModalManager {
this.attachmentModalSourceLink.hidden = false;
}

/**
* Releases the blob URL for the in-modal PDF preview
*/
_revokePdfPreviewObjectUrl() {
if (this._pdfPreviewObjectUrl) {
URL.revokeObjectURL(this._pdfPreviewObjectUrl);
this._pdfPreviewObjectUrl = null;
}
}

/**
* Resets image-specific preview state
*/
Expand Down Expand Up @@ -872,9 +891,9 @@ export class AttachmentModalManager {
attachmentsList.className = 'nested-email-attachments-list';

emailData.attachments.forEach(att => {
const isPreviewable = this.isPreviewable(att.attachMimeTag);
const isPreviewable = this.isPreviewable(att.attachMimeTag, att.fileName);
const isImage = this.isPreviewableImage(att.attachMimeTag);
const isPdf = this.isPdf(att.attachMimeTag);
const isPdf = this.isPdf(att.attachMimeTag, att.fileName);
const isText = this.isText(att.attachMimeTag);
const isEml = this.isPreviewableEml(att.attachMimeTag);

Expand Down Expand Up @@ -928,9 +947,6 @@ export class AttachmentModalManager {
if (downloadBtn) {
e.stopPropagation();
this.downloadAttachment(att);
} else if (this._shouldOpenWithSystemViewer(att)) {
// PDF in Tauri: open with system viewer instead of modal preview
this._openPdfWithSystemViewer(att);
} else {
this.pushToStack(attachment);
this.renderAttachmentPreview(att);
Expand Down Expand Up @@ -1037,6 +1053,7 @@ export class AttachmentModalManager {
close() {
if (!this.attachmentModal) return;

this._revokePdfPreviewObjectUrl();
this.attachmentModal.classList.remove('active');
this.attachmentModalContent.innerHTML = '';
this.attachmentModalContent.classList.remove('attachment-modal-content--image');
Expand Down
4 changes: 2 additions & 2 deletions src/js/ui/MessageContentRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ export class MessageContentRenderer {
*/
renderAttachmentItems(items) {
return items.map(({ attachment, index }) => {
const isPreviewable = this.attachmentModal?.isPreviewable(attachment.attachMimeTag);
const isPreviewable = this.attachmentModal?.isPreviewable(attachment.attachMimeTag, attachment.fileName);

if (isPreviewable) {
return `
Expand Down Expand Up @@ -529,7 +529,7 @@ export class MessageContentRenderer {
*/
getAttachmentItemIcon(attachment) {
const isImage = this.attachmentModal?.isPreviewableImage(attachment.attachMimeTag);
const isPdf = this.attachmentModal?.isPdf(attachment.attachMimeTag);
const isPdf = this.attachmentModal?.isPdf(attachment.attachMimeTag, attachment.fileName);
const isText = this.attachmentModal?.isText(attachment.attachMimeTag);
const isEml = this.attachmentModal?.isPreviewableEml(attachment.attachMimeTag);

Expand Down
Loading