feat: refactor MailViewer and add utility functions for email handling and attachment processing

This commit is contained in:
Flavio Fois
2026-02-05 22:25:35 +01:00
parent c0c1fbb08f
commit aef5c317df
6 changed files with 647 additions and 247 deletions

View File

@@ -1,218 +1,89 @@
<script lang="ts"> <script lang="ts">
import { X, MailOpen, Image, FileText, File, ShieldCheck, Shield, Signature, FileCode, Loader2 } from "@lucide/svelte"; import {
import { ShowOpenFileDialog, ReadEML, OpenPDF, OpenImageWindow, OpenPDFWindow, OpenImage, ReadMSG, ReadPEC, OpenEMLWindow, ConvertToUTF8, SetCurrentMailFilePath } from "$lib/wailsjs/go/main/App"; X,
import type { internal } from "$lib/wailsjs/go/models"; MailOpen,
import { sidebarOpen } from "$lib/stores/app"; Image,
import { onDestroy, onMount } from "svelte"; FileText,
import { toast } from "svelte-sonner"; File,
import { EventsOn, WindowShow, WindowUnminimise } from "$lib/wailsjs/runtime/runtime"; ShieldCheck,
import { mailState } from "$lib/stores/mail-state.svelte"; Signature,
import { settingsStore } from "$lib/stores/settings.svelte"; FileCode,
import * as m from "$lib/paraglide/messages"; Loader2,
import { dev } from "$app/environment"; } from '@lucide/svelte';
import { isBase64, isHtml } from "$lib/utils"; import { sidebarOpen } from '$lib/stores/app';
import { onDestroy, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { EventsOn, WindowShow, WindowUnminimise } from '$lib/wailsjs/runtime/runtime';
import { mailState } from '$lib/stores/mail-state.svelte';
import * as m from '$lib/paraglide/messages';
import { dev } from '$app/environment';
// Import refactored utilities
import {
IFRAME_UTIL_HTML,
CONTENT_TYPES,
PEC_FILES,
arrayBufferToBase64,
createDataUrl,
openPDFAttachment,
openImageAttachment,
openEMLAttachment,
openAndLoadEmail,
loadEmailFromPath,
processEmailBody,
isEmailFile,
} from '$lib/utils/mail';
// ============================================================================
// State
// ============================================================================
let unregisterEvents = () => {}; let unregisterEvents = () => {};
let isLoading = $state(false); let isLoading = $state(false);
let loadingText = $state(""); let loadingText = $state('');
// ============================================================================
let iFrameUtilHTML = "<style>body{margin:0;padding:20px;font-family:sans-serif;} a{pointer-events:none!important;cursor:default!important;}</style><script>function handleWheel(event){if(event.ctrlKey){event.preventDefault();}}document.addEventListener('wheel',handleWheel,{passive:false});<\/script>"; // Event Handlers
// ============================================================================
function onClear() { function onClear() {
mailState.clear(); mailState.clear();
} }
$effect(() => {
const process = async () => {
if (mailState.currentEmail?.body) {
let content = mailState.currentEmail.body;
// 1. Try to decode if not HTML
if (!isHtml(content)) {
const clean = content.replace(/[\s\r\n]+/g, '');
if (isBase64(clean)) {
try {
const decoded = window.atob(clean);
content = decoded;
} catch (e) {
console.warn("Failed to decode base64 body:", e);
}
}
}
// 2. If it is HTML (original or decoded), try to fix encoding
if (isHtml(content)) {
try {
const fixed = await ConvertToUTF8(content);
if (fixed) {
content = fixed;
}
} catch (e) {
console.warn("Failed to fix encoding:", e);
}
}
// 3. Update if changed
if (content !== mailState.currentEmail.body) {
mailState.currentEmail.body = content;
}
}
if(dev) {
console.debug("emailObj:", mailState.currentEmail)
}
console.info("Current email changed:", mailState.currentEmail?.subject);
if(mailState.currentEmail !== null) {
sidebarOpen.set(false);
}
};
process();
})
onDestroy(() => {
if (unregisterEvents) unregisterEvents();
});
onMount(async () => {
// Listen for second instance args
unregisterEvents = EventsOn("launchArgs", async (args: string[]) => {
console.log("got event launchArgs:", args);
if (args && args.length > 0) {
for (const arg of args) {
const lowerArg = arg.toLowerCase();
if (lowerArg.endsWith(".eml") || lowerArg.endsWith(".msg")) {
console.log("Loading file from second instance:", arg);
isLoading = true;
loadingText = m.layout_loading_text();
try {
let emlContent;
if (lowerArg.endsWith(".msg")) {
loadingText = m.mail_loading_msg_conversion();
emlContent = await ReadMSG(arg, true);
} else {
// EML handling
try {
emlContent = await ReadPEC(arg);
} catch (e) {
console.warn("ReadPEC failed, trying ReadEML:", e);
emlContent = await ReadEML(arg);
}
if (emlContent && emlContent.body) {
const trimmed = emlContent.body.trim();
const clean = trimmed.replace(/[\s\r\n]+/g, '');
if (clean.length > 0 && clean.length % 4 === 0 && /^[A-Za-z0-9+/]+=*$/.test(clean)) {
try {
emlContent.body = window.atob(clean);
} catch (e) { }
}
}
}
mailState.setParams(emlContent);
sidebarOpen.set(false);
WindowUnminimise();
WindowShow();
} catch (error) {
console.error("Failed to load email:", error);
toast.error("Failed to load email file");
} finally {
isLoading = false;
loadingText = "";
}
break;
}
}
}
});
});
async function openPDFHandler(base64Data: string, filename: string) {
try {
if (settingsStore.settings.useBuiltinPDFViewer) {
await OpenPDFWindow(base64Data, filename);
} else {
await OpenPDF(base64Data, filename);
}
} catch (error: string | any) {
if(error.includes(filename) && error.includes("already open")) {
toast.error(m.mail_pdf_already_open());
return;
}
console.error("Failed to open PDF:", error);
toast.error(m.mail_error_pdf());
}
}
async function openImageHandler(base64Data: string, filename: string) {
try {
if (settingsStore.settings.useBuiltinPreview) {
await OpenImageWindow(base64Data, filename);
} else {
await OpenImage(base64Data, filename);
}
} catch (error) {
console.error("Failed to open image:", error);
toast.error(m.mail_error_image());
}
}
async function openEMLHandler(base64Data: string, filename: string) {
try {
await OpenEMLWindow(base64Data, filename);
} catch (error) {
console.error("Failed to open EML:", error);
toast.error("Failed to open EML attachment");
}
}
async function onOpenMail() { async function onOpenMail() {
isLoading = true; isLoading = true;
loadingText = m.layout_loading_text(); loadingText = m.layout_loading_text();
const result = await ShowOpenFileDialog();
if (result && result.length > 0) { const result = await openAndLoadEmail();
// Handle opening the mail file
try { if (result.cancelled) {
// If the file is .eml, otherwise if is .msg, read accordingly isLoading = false;
let email: internal.EmailData; loadingText = '';
if(result.toLowerCase().endsWith(".msg")) { return;
loadingText = m.mail_loading_msg_conversion();
email = await ReadMSG(result, true);
} else {
email = await ReadEML(result);
} }
// Track the current mail file path for bug reports
await SetCurrentMailFilePath(result); if (result.success && result.email) {
mailState.setParams(email); mailState.setParams(result.email);
sidebarOpen.set(false); sidebarOpen.set(false);
} else if (result.error) {
} catch (error) { console.error('Failed to read email file:', result.error);
console.error("Failed to read EML file:", error);
toast.error(m.mail_error_opening()); toast.error(m.mail_error_opening());
} finally {
isLoading = false;
loadingText = "";
}
} else {
isLoading = false;
loadingText = "";
}
} }
function arrayBufferToBase64(buffer: any): string { isLoading = false;
if (typeof buffer === "string") return buffer; // Already base64 string loadingText = '';
if (Array.isArray(buffer)) {
let binary = "";
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
} }
return window.btoa(binary);
async function handleOpenPDF(base64Data: string, filename: string) {
await openPDFAttachment(base64Data, filename);
} }
return "";
async function handleOpenImage(base64Data: string, filename: string) {
await openImageAttachment(base64Data, filename);
}
async function handleOpenEML(base64Data: string, filename: string) {
await openEMLAttachment(base64Data, filename);
} }
function handleWheel(event: WheelEvent) { function handleWheel(event: WheelEvent) {
@@ -220,6 +91,102 @@
event.preventDefault(); event.preventDefault();
} }
} }
// ============================================================================
// Effects
// ============================================================================
// Process email body when current email changes
$effect(() => {
const processCurrentEmail = async () => {
if (mailState.currentEmail?.body) {
const processedBody = await processEmailBody(mailState.currentEmail.body);
if (processedBody !== mailState.currentEmail.body) {
mailState.currentEmail.body = processedBody;
}
}
if (dev) {
console.debug('emailObj:', mailState.currentEmail);
}
console.info('Current email changed:', mailState.currentEmail?.subject);
if (mailState.currentEmail !== null) {
sidebarOpen.set(false);
}
};
processCurrentEmail();
});
// ============================================================================
// Lifecycle
// ============================================================================
onMount(async () => {
// Listen for second instance args (when another file is opened while app is running)
unregisterEvents = EventsOn('launchArgs', async (args: string[]) => {
console.log('got event launchArgs:', args);
if (!args || args.length === 0) return;
for (const arg of args) {
if (isEmailFile(arg)) {
console.log('Loading file from second instance:', arg);
isLoading = true;
loadingText = m.layout_loading_text();
// Check if MSG file for special loading text
if (arg.toLowerCase().endsWith('.msg')) {
loadingText = m.mail_loading_msg_conversion();
}
const result = await loadEmailFromPath(arg);
if (result.success && result.email) {
mailState.setParams(result.email);
sidebarOpen.set(false);
WindowUnminimise();
WindowShow();
} else if (result.error) {
console.error('Failed to load email:', result.error);
toast.error('Failed to load email file');
}
isLoading = false;
loadingText = '';
break;
}
}
});
});
onDestroy(() => {
if (unregisterEvents) {
unregisterEvents();
}
});
// ============================================================================
// Helpers
// ============================================================================
function getAttachmentClass(att: { contentType: string; filename: string }): string {
if (att.contentType.startsWith(CONTENT_TYPES.IMAGE)) return 'image';
if (att.contentType === CONTENT_TYPES.PDF || att.filename.toLowerCase().endsWith('.pdf'))
return 'pdf';
if (att.filename.toLowerCase().endsWith('.eml')) return 'eml';
return 'file';
}
function isPecSignature(filename: string, isPec: boolean): boolean {
return isPec && filename.toLowerCase().endsWith(PEC_FILES.SIGNATURE);
}
function isPecCertificate(filename: string, isPec: boolean): boolean {
return isPec && filename.toLowerCase() === PEC_FILES.CERTIFICATE;
}
</script> </script>
<div class="panel fill" aria-label="Events"> <div class="panel fill" aria-label="Events">
@@ -229,8 +196,10 @@
<div class="loading-text">{loadingText}</div> <div class="loading-text">{loadingText}</div>
</div> </div>
{/if} {/if}
<div class="events" role="log" aria-live="polite"> <div class="events" role="log" aria-live="polite">
{#if mailState.currentEmail === null} {#if mailState.currentEmail === null}
<!-- Empty State -->
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon"> <div class="empty-icon">
<MailOpen size="48" strokeWidth={1} /> <MailOpen size="48" strokeWidth={1} />
@@ -241,7 +210,9 @@
</button> </button>
</div> </div>
{:else} {:else}
<!-- Email View -->
<div class="email-view"> <div class="email-view">
<!-- Header -->
<div class="email-header-content"> <div class="email-header-content">
<div class="subject-row"> <div class="subject-row">
<div class="email-subject"> <div class="email-subject">
@@ -255,7 +226,7 @@
title={m.mail_open_btn_title()} title={m.mail_open_btn_title()}
disabled={isLoading} disabled={isLoading}
> >
<MailOpen size="15" ></MailOpen> <MailOpen size="15" />
{m.mail_open_btn_text()} {m.mail_open_btn_text()}
</button> </button>
<button <button
@@ -271,77 +242,84 @@
</div> </div>
</div> </div>
<!-- Meta Grid -->
<div class="email-meta-grid"> <div class="email-meta-grid">
<span class="label">{m.mail_from()}</span> <span class="label">{m.mail_from()}</span>
<span class="value">{mailState.currentEmail.from}</span> <span class="value">{mailState.currentEmail.from}</span>
{#if mailState.currentEmail.to && mailState.currentEmail.to.length > 0} {#if mailState.currentEmail.to && mailState.currentEmail.to.length > 0}
<span class="label">{m.mail_to()}</span> <span class="label">{m.mail_to()}</span>
<span class="value">{mailState.currentEmail.to.join(", ")}</span> <span class="value">{mailState.currentEmail.to.join(', ')}</span>
{/if} {/if}
{#if mailState.currentEmail.cc && mailState.currentEmail.cc.length > 0} {#if mailState.currentEmail.cc && mailState.currentEmail.cc.length > 0}
<span class="label">{m.mail_cc()}</span> <span class="label">{m.mail_cc()}</span>
<span class="value">{mailState.currentEmail.cc.join(", ")}</span> <span class="value">{mailState.currentEmail.cc.join(', ')}</span>
{/if} {/if}
{#if mailState.currentEmail.bcc && mailState.currentEmail.bcc.length > 0} {#if mailState.currentEmail.bcc && mailState.currentEmail.bcc.length > 0}
<span class="label">{m.mail_bcc()}</span> <span class="label">{m.mail_bcc()}</span>
<span class="value">{mailState.currentEmail.bcc.join(", ")}</span> <span class="value">{mailState.currentEmail.bcc.join(', ')}</span>
{/if} {/if}
{#if mailState.currentEmail.isPec} {#if mailState.currentEmail.isPec}
<span class="label">{m.mail_sign_label()}</span> <span class="label">{m.mail_sign_label()}</span>
<span class="value"><span class="pec-badge" title="Posta Elettronica Certificata"> <span class="value">
<span class="pec-badge" title="Posta Elettronica Certificata">
<ShieldCheck size="14" /> <ShieldCheck size="14" />
PEC PEC
</span></span> </span>
</span>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Attachments -->
<div class="email-attachments"> <div class="email-attachments">
<span class="att-section-label">{m.mail_attachments()}</span> <span class="att-section-label">{m.mail_attachments()}</span>
<div class="att-list"> <div class="att-list">
{#if mailState.currentEmail.attachments && mailState.currentEmail.attachments.length > 0} {#if mailState.currentEmail.attachments && mailState.currentEmail.attachments.length > 0}
{#each mailState.currentEmail.attachments as att} {#each mailState.currentEmail.attachments as att}
{#if att.contentType.startsWith("image/")} {@const base64 = arrayBufferToBase64(att.data)}
{@const isImage = att.contentType.startsWith(CONTENT_TYPES.IMAGE)}
{@const isPdf =
att.contentType === CONTENT_TYPES.PDF ||
att.filename.toLowerCase().endsWith('.pdf')}
{@const isEml = att.filename.toLowerCase().endsWith('.eml')}
{@const isPecSig = isPecSignature(att.filename, mailState.currentEmail.isPec)}
{@const isPecCert = isPecCertificate(att.filename, mailState.currentEmail.isPec)}
{#if isImage}
<button <button
class="att-btn image" class="att-btn image"
onclick={() => openImageHandler(arrayBufferToBase64(att.data), att.filename)} onclick={() => handleOpenImage(base64, att.filename)}
> >
<Image size="14" /> <Image size="14" />
<span class="att-name">{att.filename}</span> <span class="att-name">{att.filename}</span>
</button> </button>
{:else if att.contentType === "application/pdf" || att.filename.toLowerCase().endsWith(".pdf")} {:else if isPdf}
<button <button class="att-btn pdf" onclick={() => handleOpenPDF(base64, att.filename)}>
class="att-btn pdf" <FileText size="14" />
onclick={() => openPDFHandler(arrayBufferToBase64(att.data), att.filename)}
>
<FileText />
<span class="att-name">{att.filename}</span> <span class="att-name">{att.filename}</span>
</button> </button>
{:else if att.filename.toLowerCase().endsWith(".eml")} {:else if isEml}
<button <button class="att-btn eml" onclick={() => handleOpenEML(base64, att.filename)}>
class="att-btn eml"
onclick={() => openEMLHandler(arrayBufferToBase64(att.data), att.filename)}
>
<MailOpen size="14" /> <MailOpen size="14" />
<span class="att-name">{att.filename}</span> <span class="att-name">{att.filename}</span>
</button> </button>
{:else if mailState.currentEmail.isPec && att.filename.toLowerCase().endsWith(".p7s")} {:else if isPecSig}
<a <a
class="att-btn file" class="att-btn file"
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`} href={createDataUrl(att.contentType, base64)}
download={att.filename} download={att.filename}
> >
<Signature size="14" /> <Signature size="14" />
<span class="att-name">{att.filename}</span> <span class="att-name">{att.filename}</span>
</a> </a>
{:else if mailState.currentEmail.isPec && att.filename.toLowerCase() === "daticert.xml"} {:else if isPecCert}
<a <a
class="att-btn file" class="att-btn file"
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`} href={createDataUrl(att.contentType, base64)}
download={att.filename} download={att.filename}
> >
<FileCode size="14" /> <FileCode size="14" />
@@ -350,10 +328,10 @@
{:else} {:else}
<a <a
class="att-btn file" class="att-btn file"
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`} href={createDataUrl(att.contentType, base64)}
download={att.filename} download={att.filename}
> >
{#if att.contentType.startsWith("image/")} {#if isImage}
<Image size="14" /> <Image size="14" />
{:else} {:else}
<File size="14" /> <File size="14" />
@@ -368,9 +346,10 @@
</div> </div>
</div> </div>
<!-- Email Body -->
<div class="email-body-wrapper"> <div class="email-body-wrapper">
<iframe <iframe
srcdoc={mailState.currentEmail.body + iFrameUtilHTML} srcdoc={mailState.currentEmail.body + IFRAME_UTIL_HTML}
title="Email Body" title="Email Body"
class="email-iframe" class="email-iframe"
sandbox="allow-same-origin allow-scripts" sandbox="allow-same-origin allow-scripts"
@@ -399,14 +378,17 @@
gap: 16px; gap: 16px;
} }
/* Make sure internal loader spins if not using class-based animation library like Tailwind */
:global(.spinner) { :global(.spinner) {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
.loading-text { .loading-text {
@@ -565,14 +547,29 @@
color: #fff; color: #fff;
} }
.att-btn.image { color: #60a5fa; border-color: rgba(96, 165, 250, 0.3); } .att-btn.image {
.att-btn.image:hover { color: #93c5fd; } color: #60a5fa;
border-color: rgba(96, 165, 250, 0.3);
}
.att-btn.image:hover {
color: #93c5fd;
}
.att-btn.pdf { color: #f87171; border-color: rgba(248, 113, 113, 0.3); } .att-btn.pdf {
.att-btn.pdf:hover { color: #fca5a5; } color: #f87171;
border-color: rgba(248, 113, 113, 0.3);
}
.att-btn.pdf:hover {
color: #fca5a5;
}
.att-btn.eml { color: hsl(49, 80%, 49%); border-color: rgba(224, 206, 39, 0.3); } .att-btn.eml {
.att-btn.eml:hover { color: hsl(49, 80%, 65%); } color: hsl(49, 80%, 49%);
border-color: rgba(224, 206, 39, 0.3);
}
.att-btn.eml:hover {
color: hsl(49, 80%, 65%);
}
.att-name { .att-name {
white-space: nowrap; white-space: nowrap;
@@ -635,7 +632,8 @@
border-color: rgba(255, 255, 255, 0.25); border-color: rgba(255, 255, 255, 0.25);
} }
.browse-btn:disabled, .btn:disabled { .browse-btn:disabled,
.btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
pointer-events: none; pointer-events: none;

View File

@@ -0,0 +1,94 @@
/**
* Handlers for opening different attachment types
*/
import {
OpenPDF,
OpenPDFWindow,
OpenImage,
OpenImageWindow,
OpenEMLWindow,
} from '$lib/wailsjs/go/main/App';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toast } from 'svelte-sonner';
import * as m from '$lib/paraglide/messages';
export interface AttachmentHandlerResult {
success: boolean;
error?: string;
}
/**
* Opens a PDF attachment using either built-in or external viewer
* @param base64Data - Base64 encoded PDF data
* @param filename - Name of the PDF file
*/
export async function openPDFAttachment(
base64Data: string,
filename: string
): Promise<AttachmentHandlerResult> {
try {
if (settingsStore.settings.useBuiltinPDFViewer) {
await OpenPDFWindow(base64Data, filename);
} else {
await OpenPDF(base64Data, filename);
}
return { success: true };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Check if PDF is already open
if (errorMessage.includes(filename) && errorMessage.includes('already open')) {
toast.error(m.mail_pdf_already_open());
return { success: false, error: 'already_open' };
}
console.error('Failed to open PDF:', error);
toast.error(m.mail_error_pdf());
return { success: false, error: errorMessage };
}
}
/**
* Opens an image attachment using either built-in or external viewer
* @param base64Data - Base64 encoded image data
* @param filename - Name of the image file
*/
export async function openImageAttachment(
base64Data: string,
filename: string
): Promise<AttachmentHandlerResult> {
try {
if (settingsStore.settings.useBuiltinPreview) {
await OpenImageWindow(base64Data, filename);
} else {
await OpenImage(base64Data, filename);
}
return { success: true };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to open image:', error);
toast.error(m.mail_error_image());
return { success: false, error: errorMessage };
}
}
/**
* Opens an EML attachment in a new EMLy window
* @param base64Data - Base64 encoded EML data
* @param filename - Name of the EML file
*/
export async function openEMLAttachment(
base64Data: string,
filename: string
): Promise<AttachmentHandlerResult> {
try {
await OpenEMLWindow(base64Data, filename);
return { success: true };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to open EML:', error);
toast.error('Failed to open EML attachment');
return { success: false, error: errorMessage };
}
}

View File

@@ -0,0 +1,31 @@
/**
* HTML/CSS injected into the email body iframe for styling and security
* - Removes default body margins
* - Disables link clicking for security
* - Prevents Ctrl+Wheel zoom in iframe
*/
export const IFRAME_UTIL_HTML = `<style>body{margin:0;padding:20px;font-family:sans-serif;} a{pointer-events:none!important;cursor:default!important;}</style><script>function handleWheel(event){if(event.ctrlKey){event.preventDefault();}}document.addEventListener('wheel',handleWheel,{passive:false});<\/script>`;
/**
* Supported email file extensions
*/
export const EMAIL_EXTENSIONS = {
EML: '.eml',
MSG: '.msg',
} as const;
/**
* Attachment content type prefixes/patterns
*/
export const CONTENT_TYPES = {
IMAGE: 'image/',
PDF: 'application/pdf',
} as const;
/**
* Special PEC (Italian Certified Email) file names
*/
export const PEC_FILES = {
SIGNATURE: '.p7s',
CERTIFICATE: 'daticert.xml',
} as const;

View File

@@ -0,0 +1,77 @@
/**
* Utility functions for mail data conversion and processing
*/
/**
* Converts an ArrayBuffer or byte array to a base64 string
* @param buffer - The buffer to convert (can be string, array, or ArrayBuffer)
* @returns Base64 encoded string
*/
export function arrayBufferToBase64(buffer: unknown): string {
// Already a base64 string
if (typeof buffer === 'string') {
return buffer;
}
// Handle array of bytes
if (Array.isArray(buffer)) {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
// Handle ArrayBuffer
if (buffer instanceof ArrayBuffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
return '';
}
/**
* Creates a data URL for downloading attachments
* @param contentType - MIME type of the attachment
* @param base64Data - Base64 encoded data
* @returns Data URL string
*/
export function createDataUrl(contentType: string, base64Data: string): string {
return `data:${contentType};base64,${base64Data}`;
}
/**
* Checks if a string looks like valid base64 content
* @param content - String to check
* @returns True if the content appears to be base64 encoded
*/
export function looksLikeBase64(content: string): boolean {
const clean = content.replace(/[\s\r\n]+/g, '');
return (
clean.length > 0 &&
clean.length % 4 === 0 &&
/^[A-Za-z0-9+/]+=*$/.test(clean)
);
}
/**
* Attempts to decode base64 content
* @param content - Base64 string to decode
* @returns Decoded string or null if decoding fails
*/
export function tryDecodeBase64(content: string): string | null {
try {
const clean = content.replace(/[\s\r\n]+/g, '');
return window.atob(clean);
} catch {
return null;
}
}

View File

@@ -0,0 +1,163 @@
/**
* Email loading and processing utilities
*/
import {
ReadEML,
ReadMSG,
ReadPEC,
ShowOpenFileDialog,
SetCurrentMailFilePath,
ConvertToUTF8,
} from '$lib/wailsjs/go/main/App';
import type { internal } from '$lib/wailsjs/go/models';
import { isBase64, isHtml } from '$lib/utils';
import { looksLikeBase64, tryDecodeBase64 } from './data-utils';
export interface LoadEmailResult {
success: boolean;
email?: internal.EmailData;
filePath?: string;
error?: string;
cancelled?: boolean;
}
/**
* Determines the email file type from the path
*/
export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
const lowerPath = filePath.toLowerCase();
if (lowerPath.endsWith('.eml')) return 'eml';
if (lowerPath.endsWith('.msg')) return 'msg';
return null;
}
/**
* Checks if a file path is a valid email file
*/
export function isEmailFile(filePath: string): boolean {
return getEmailFileType(filePath) !== null;
}
/**
* Loads an email from a file path
* @param filePath - Path to the email file
* @returns LoadEmailResult with the email data or error
*/
export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResult> {
const fileType = getEmailFileType(filePath);
if (!fileType) {
return {
success: false,
error: 'Invalid file type. Only .eml and .msg files are supported.',
};
}
try {
let email: internal.EmailData;
if (fileType === 'msg') {
email = await ReadMSG(filePath, true);
} else {
// Try PEC first, fall back to regular EML
try {
email = await ReadPEC(filePath);
} catch {
email = await ReadEML(filePath);
}
}
// Process body if needed (decode base64)
if (email?.body) {
const trimmed = email.body.trim();
if (looksLikeBase64(trimmed)) {
const decoded = tryDecodeBase64(trimmed);
if (decoded) {
email.body = decoded;
}
}
}
return {
success: true,
email,
filePath,
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to load email:', error);
return {
success: false,
error: errorMessage,
};
}
}
/**
* Opens a file dialog and loads the selected email
* @returns LoadEmailResult with the email data or error
*/
export async function openAndLoadEmail(): Promise<LoadEmailResult> {
try {
const filePath = await ShowOpenFileDialog();
if (!filePath || filePath.length === 0) {
return { success: false, cancelled: true };
}
const result = await loadEmailFromPath(filePath);
if (result.success && result.email) {
// Track the current mail file path for bug reports
await SetCurrentMailFilePath(filePath);
}
return result;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to open email file:', error);
return {
success: false,
error: errorMessage,
};
}
}
/**
* Processes and fixes the email body content
* - Decodes base64 content if needed
* - Fixes character encoding issues
* @param body - The raw email body
* @returns Processed body content
*/
export async function processEmailBody(body: string): Promise<string> {
if (!body) return body;
let content = body;
// 1. Try to decode if not HTML
if (!isHtml(content)) {
const clean = content.replace(/[\s\r\n]+/g, '');
if (isBase64(clean)) {
const decoded = tryDecodeBase64(clean);
if (decoded) {
content = decoded;
}
}
}
// 2. If it is HTML (original or decoded), try to fix encoding
if (isHtml(content)) {
try {
const fixed = await ConvertToUTF8(content);
if (fixed) {
content = fixed;
}
} catch (e) {
console.warn('Failed to fix encoding:', e);
}
}
return content;
}

View File

@@ -0,0 +1,37 @@
/**
* Mail utilities barrel export
*/
// Constants
export {
IFRAME_UTIL_HTML,
EMAIL_EXTENSIONS,
CONTENT_TYPES,
PEC_FILES,
} from './constants';
// Data utilities
export {
arrayBufferToBase64,
createDataUrl,
looksLikeBase64,
tryDecodeBase64,
} from './data-utils';
// Attachment handlers
export {
openPDFAttachment,
openImageAttachment,
openEMLAttachment,
type AttachmentHandlerResult,
} from './attachment-handlers';
// Email loader
export {
getEmailFileType,
isEmailFile,
loadEmailFromPath,
openAndLoadEmail,
processEmailBody,
type LoadEmailResult,
} from './email-loader';