Compare commits

...

4 Commits

Author SHA1 Message Date
Flavio Fois
654475d3ea feat: refactor MailViewer component path 2026-02-04 23:41:16 +01:00
Flavio Fois
3c24421c8c fix: update installer script to use ApplicationName variable for consistency 2026-02-04 23:32:12 +01:00
Flavio Fois
1e84320588 feat: update application versioning and setup configuration 2026-02-04 23:30:40 +01:00
Flavio Fois
e7d1850a63 feat: update SDK and GUI versions, add debugger protection settings
- Updated SDK_DECODER_SEMVER to "1.3.0" and GUI_SEMVER to "1.2.4" in config.ini.
- Updated MailViewer component to handle PDF already open error and improved iframe handling.
- Removed deprecated useMsgConverter setting from settings page.
- Added IsDebuggerRunning function to check for attached debuggers and quit the app if detected.
- Enhanced PDF viewer to prevent infinite loading and improved error handling.

Co-Authored-By: Laky-64 <iraci.matteo@gmail.com>
2026-02-04 23:25:20 +01:00
26 changed files with 1056 additions and 709 deletions

11
app.go
View File

@@ -101,12 +101,12 @@ func (a *App) ReadMSG(filePath string, useExternalConverter bool) (*internal.Ema
if useExternalConverter { if useExternalConverter {
return internal.ReadMsgFile(filePath) return internal.ReadMsgFile(filePath)
} }
return internal.OSSReadMsgFile(filePath) return internal.ReadMsgFile(filePath)
} }
// ReadMSGOSS reads a .msg file and returns the email data // ReadMSGOSS reads a .msg file and returns the email data
func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) { func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) {
return internal.OSSReadMsgFile(filePath) return internal.ReadMsgFile(filePath)
} }
// ShowOpenFileDialog shows the file open dialog for EML files // ShowOpenFileDialog shows the file open dialog for EML files
@@ -497,3 +497,10 @@ func (a *App) OpenDefaultAppsSettings() error {
cmd := exec.Command("cmd", "/c", "start", "ms-settings:defaultapps") cmd := exec.Command("cmd", "/c", "start", "ms-settings:defaultapps")
return cmd.Start() return cmd.Start()
} }
func (a *App) IsDebuggerRunning() bool {
if a == nil {
return false
}
return utils.IsDebugged()
}

View File

@@ -0,0 +1,11 @@
package utils
import "syscall"
// IsDebugged reports whether a debugger is attached (Windows).
func IsDebugged() bool {
kernel32 := syscall.NewLazyDLL("kernel32.dll")
isDebuggerPresent := kernel32.NewProc("IsDebuggerPresent")
ret, _, _ := isDebuggerPresent.Call()
return ret != 0
}

View File

@@ -1,121 +0,0 @@
package internal
import (
"bytes"
"fmt"
"io"
"net/mail"
"os"
"strings"
"emly/backend/utils"
)
// ReadPecInnerEml reads the inner email (postacert.eml) from a PEC EML file.
// It opens the outer file, looks for the specific attachment, and parses it.
func ReadPecInnerEml(filePath string) (*EmailData, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// 1. Parse outer "Envelope"
outerEmail, err := utils.Parse(file)
if err != nil {
return nil, fmt.Errorf("failed to parse outer email: %w", err)
}
// 2. Look for the real content inside postacert.eml
var innerEmailData []byte
foundPec := false
for _, att := range outerEmail.Attachments {
// Standard PEC puts the real message in postacert.eml
// Using case-insensitive check and substring as per example
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
data, err := io.ReadAll(att.Data)
if err != nil {
return nil, fmt.Errorf("failed to read inner email content: %w", err)
}
innerEmailData = data
foundPec = true
break
}
}
if !foundPec {
return nil, fmt.Errorf("not a signed PEC or 'postacert.eml' attachment is missing")
}
// 3. Parse the inner EML content
innerEmail, err := utils.Parse(bytes.NewReader(innerEmailData))
if err != nil {
return nil, fmt.Errorf("failed to parse inner email structure: %w", err)
}
// Helper to format addresses (reused logic pattern from eml_reader.go)
formatAddress := func(addr []*mail.Address) []string {
var result []string
for _, a := range addr {
// convertToUTF8 is defined in eml_reader.go (same package)
result = append(result, convertToUTF8(a.String()))
}
return result
}
// Determine body (prefer HTML)
body := innerEmail.HTMLBody
if body == "" {
body = innerEmail.TextBody
}
// Process attachments of the inner email
var attachments []EmailAttachment
var hasDatiCert, hasSmime, hasInnerPecEmail bool
for _, att := range innerEmail.Attachments {
data, err := io.ReadAll(att.Data)
if err != nil {
continue
}
// Check internal flags for the inner email (recursive PEC check?)
filenameLower := strings.ToLower(att.Filename)
if filenameLower == "daticert.xml" {
hasDatiCert = true
}
if filenameLower == "smime.p7s" {
hasSmime = true
}
if strings.HasSuffix(filenameLower, ".eml") {
hasInnerPecEmail = true
}
attachments = append(attachments, EmailAttachment{
Filename: att.Filename,
ContentType: att.ContentType,
Data: data,
})
}
isPec := hasDatiCert && hasSmime
// Format From
var from string
if len(innerEmail.From) > 0 {
from = innerEmail.From[0].String()
}
return &EmailData{
From: convertToUTF8(from),
To: formatAddress(innerEmail.To),
Cc: formatAddress(innerEmail.Cc),
Bcc: formatAddress(innerEmail.Bcc),
Subject: convertToUTF8(innerEmail.Subject),
Body: convertToUTF8(body),
Attachments: attachments,
IsPec: isPec,
HasInnerEmail: hasInnerPecEmail,
}, nil
}

View File

@@ -1,6 +1,7 @@
package internal package internal
import ( import (
"bytes"
"fmt" "fmt"
"io" "io"
"net/mail" "net/mail"
@@ -8,8 +9,6 @@ import (
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"emly/backend/utils"
"golang.org/x/text/encoding/charmap" "golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform" "golang.org/x/text/transform"
) )
@@ -39,7 +38,7 @@ func ReadEmlFile(filePath string) (*EmailData, error) {
} }
defer file.Close() defer file.Close()
email, err := utils.Parse(file) email, err := Parse(file)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse email: %w", err) return nil, fmt.Errorf("failed to parse email: %w", err)
} }
@@ -122,3 +121,110 @@ func convertToUTF8(s string) string {
} }
return decoded return decoded
} }
func ReadPecInnerEml(filePath string) (*EmailData, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// 1. Parse outer "Envelope"
outerEmail, err := Parse(file)
if err != nil {
return nil, fmt.Errorf("failed to parse outer email: %w", err)
}
// 2. Look for the real content inside postacert.eml
var innerEmailData []byte
foundPec := false
for _, att := range outerEmail.Attachments {
// Standard PEC puts the real message in postacert.eml
// Using case-insensitive check and substring as per example
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
data, err := io.ReadAll(att.Data)
if err != nil {
return nil, fmt.Errorf("failed to read inner email content: %w", err)
}
innerEmailData = data
foundPec = true
break
}
}
if !foundPec {
return nil, fmt.Errorf("not a signed PEC or 'postacert.eml' attachment is missing")
}
// 3. Parse the inner EML content
innerEmail, err := Parse(bytes.NewReader(innerEmailData))
if err != nil {
return nil, fmt.Errorf("failed to parse inner email structure: %w", err)
}
// Helper to format addresses (reused logic pattern from eml_reader.go)
formatAddress := func(addr []*mail.Address) []string {
var result []string
for _, a := range addr {
// convertToUTF8 is defined in eml_reader.go (same package)
result = append(result, convertToUTF8(a.String()))
}
return result
}
// Determine body (prefer HTML)
body := innerEmail.HTMLBody
if body == "" {
body = innerEmail.TextBody
}
// Process attachments of the inner email
var attachments []EmailAttachment
var hasDatiCert, hasSmime, hasInnerPecEmail bool
for _, att := range innerEmail.Attachments {
data, err := io.ReadAll(att.Data)
if err != nil {
continue
}
// Check internal flags for the inner email (recursive PEC check?)
filenameLower := strings.ToLower(att.Filename)
if filenameLower == "daticert.xml" {
hasDatiCert = true
}
if filenameLower == "smime.p7s" {
hasSmime = true
}
if strings.HasSuffix(filenameLower, ".eml") {
hasInnerPecEmail = true
}
attachments = append(attachments, EmailAttachment{
Filename: att.Filename,
ContentType: att.ContentType,
Data: data,
})
}
isPec := hasDatiCert && hasSmime
// Format From
var from string
if len(innerEmail.From) > 0 {
from = innerEmail.From[0].String()
}
return &EmailData{
From: convertToUTF8(from),
To: formatAddress(innerEmail.To),
Cc: formatAddress(innerEmail.Cc),
Bcc: formatAddress(innerEmail.Bcc),
Subject: convertToUTF8(innerEmail.Subject),
Body: convertToUTF8(body),
Attachments: attachments,
IsPec: isPec,
HasInnerEmail: hasInnerPecEmail,
}, nil
}

View File

@@ -1,11 +1,10 @@
package utils package internal
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"mime" "mime"
"mime/multipart" "mime/multipart"
"mime/quotedprintable" "mime/quotedprintable"
@@ -14,11 +13,13 @@ import (
"time" "time"
) )
const contentTypeMultipartMixed = "multipart/mixed" const (
const contentTypeMultipartAlternative = "multipart/alternative" contentTypeMultipartMixed = "multipart/mixed"
const contentTypeMultipartRelated = "multipart/related" contentTypeMultipartAlternative = "multipart/alternative"
const contentTypeTextHtml = "text/html" contentTypeMultipartRelated = "multipart/related"
const contentTypeTextPlain = "text/plain" contentTypeTextHtml = "text/html"
contentTypeTextPlain = "text/plain"
)
// Parse an email message read from io.Reader into parsemail.Email struct // Parse an email message read from io.Reader into parsemail.Email struct
func Parse(r io.Reader) (email Email, err error) { func Parse(r io.Reader) (email Email, err error) {
@@ -46,10 +47,10 @@ func Parse(r io.Reader) (email Email, err error) {
case contentTypeMultipartRelated: case contentTypeMultipartRelated:
email.TextBody, email.HTMLBody, email.EmbeddedFiles, err = parseMultipartRelated(msg.Body, params["boundary"]) email.TextBody, email.HTMLBody, email.EmbeddedFiles, err = parseMultipartRelated(msg.Body, params["boundary"])
case contentTypeTextPlain: case contentTypeTextPlain:
message, _ := ioutil.ReadAll(msg.Body) message, _ := io.ReadAll(msg.Body)
email.TextBody = strings.TrimSuffix(string(message[:]), "\n") email.TextBody = strings.TrimSuffix(string(message[:]), "\n")
case contentTypeTextHtml: case contentTypeTextHtml:
message, _ := ioutil.ReadAll(msg.Body) message, _ := io.ReadAll(msg.Body)
email.HTMLBody = strings.TrimSuffix(string(message[:]), "\n") email.HTMLBody = strings.TrimSuffix(string(message[:]), "\n")
default: default:
email.Content, err = decodeContent(msg.Body, msg.Header.Get("Content-Transfer-Encoding")) email.Content, err = decodeContent(msg.Body, msg.Header.Get("Content-Transfer-Encoding"))
@@ -122,14 +123,14 @@ func parseMultipartRelated(msg io.Reader, boundary string) (textBody, htmlBody s
switch contentType { switch contentType {
case contentTypeTextPlain: case contentTypeTextPlain:
ppContent, err := ioutil.ReadAll(part) ppContent, err := io.ReadAll(part)
if err != nil { if err != nil {
return textBody, htmlBody, embeddedFiles, err return textBody, htmlBody, embeddedFiles, err
} }
textBody += strings.TrimSuffix(string(ppContent[:]), "\n") textBody += strings.TrimSuffix(string(ppContent[:]), "\n")
case contentTypeTextHtml: case contentTypeTextHtml:
ppContent, err := ioutil.ReadAll(part) ppContent, err := io.ReadAll(part)
if err != nil { if err != nil {
return textBody, htmlBody, embeddedFiles, err return textBody, htmlBody, embeddedFiles, err
} }
@@ -179,14 +180,14 @@ func parseMultipartAlternative(msg io.Reader, boundary string) (textBody, htmlBo
switch contentType { switch contentType {
case contentTypeTextPlain: case contentTypeTextPlain:
ppContent, err := ioutil.ReadAll(part) ppContent, err := io.ReadAll(part)
if err != nil { if err != nil {
return textBody, htmlBody, embeddedFiles, err return textBody, htmlBody, embeddedFiles, err
} }
textBody += strings.TrimSuffix(string(ppContent[:]), "\n") textBody += strings.TrimSuffix(string(ppContent[:]), "\n")
case contentTypeTextHtml: case contentTypeTextHtml:
ppContent, err := ioutil.ReadAll(part) ppContent, err := io.ReadAll(part)
if err != nil { if err != nil {
return textBody, htmlBody, embeddedFiles, err return textBody, htmlBody, embeddedFiles, err
} }
@@ -249,14 +250,14 @@ func parseMultipartMixed(msg io.Reader, boundary string) (textBody, htmlBody str
return textBody, htmlBody, attachments, embeddedFiles, err return textBody, htmlBody, attachments, embeddedFiles, err
} }
} else if contentType == contentTypeTextPlain { } else if contentType == contentTypeTextPlain {
ppContent, err := ioutil.ReadAll(part) ppContent, err := io.ReadAll(part)
if err != nil { if err != nil {
return textBody, htmlBody, attachments, embeddedFiles, err return textBody, htmlBody, attachments, embeddedFiles, err
} }
textBody += strings.TrimSuffix(string(ppContent[:]), "\n") textBody += strings.TrimSuffix(string(ppContent[:]), "\n")
} else if contentType == contentTypeTextHtml { } else if contentType == contentTypeTextHtml {
ppContent, err := ioutil.ReadAll(part) ppContent, err := io.ReadAll(part)
if err != nil { if err != nil {
return textBody, htmlBody, attachments, embeddedFiles, err return textBody, htmlBody, attachments, embeddedFiles, err
} }
@@ -354,21 +355,21 @@ func decodeContent(content io.Reader, encoding string) (io.Reader, error) {
switch encoding { switch encoding {
case "base64": case "base64":
decoded := base64.NewDecoder(base64.StdEncoding, content) decoded := base64.NewDecoder(base64.StdEncoding, content)
b, err := ioutil.ReadAll(decoded) b, err := io.ReadAll(decoded)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return bytes.NewReader(b), nil return bytes.NewReader(b), nil
case "7bit": case "7bit":
dd, err := ioutil.ReadAll(content) dd, err := io.ReadAll(content)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return bytes.NewReader(dd), nil return bytes.NewReader(dd), nil
case "8bit", "binary", "": case "8bit", "binary", "":
dd, err := ioutil.ReadAll(content) dd, err := io.ReadAll(content)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -376,7 +377,7 @@ func decodeContent(content io.Reader, encoding string) (io.Reader, error) {
return bytes.NewReader(dd), nil return bytes.NewReader(dd), nil
case "quoted-printable": case "quoted-printable":
decoded := quotedprintable.NewReader(content) decoded := quotedprintable.NewReader(content)
dd, err := ioutil.ReadAll(decoded) dd, err := io.ReadAll(decoded)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -1,261 +0,0 @@
package internal
import (
"encoding/base64"
"fmt"
"io"
"os"
"strings"
"github.com/richardlehane/mscfb"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
// MAPI Property Tags
const (
prSubject = "0037"
prBody = "1000"
prBodyHTML = "1013"
prSenderName = "0C1A"
prSenderEmail = "0C1F"
prDisplayTo = "0E04" // Display list of To recipients
prDisplayCc = "0E03"
prDisplayBcc = "0E02"
prMessageHeaders = "007D"
prClientSubmitTime = "0039" // Date
prAttachLongFilename = "3707"
prAttachFilename = "3704"
prAttachData = "3701"
prAttachMimeTag = "370E"
)
// MAPI Property Types
const (
ptUnicode = "001F"
ptString8 = "001E"
ptBinary = "0102"
)
type msgParser struct {
reader *mscfb.Reader
props map[string][]byte
}
func parseMsgFile(filePath string) (*EmailData, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
doc, err := mscfb.New(f)
if err != nil {
return nil, err
}
email := &EmailData{
To: []string{},
Cc: []string{},
Bcc: []string{},
}
// We need to iterate through the entries to find properties and attachments
// Since mscfb is a sequential reader, we might need to be careful.
// However, usually properties are in streams.
// Strategy:
// 1. Read all streams into a map keyed by their path/name for easier access?
// MSG files can be large (attachments), so maybe not all.
// 2. Identify properties from their stream names directly.
// Simplified approach: scan for stream names matching our patterns.
// Better approach:
// The Root Entry has "properties".
// We need to detect if we are in an attachment storage.
// Since mscfb iterates flat (Post-Order?), we can track context?
// mscfb File struct provides Name and path.
attachmentsMap := make(map[string]*EmailAttachment)
for entry, err := doc.Next(); err == nil; entry, err = doc.Next() {
name := entry.Name
// Check if it's a property stream
if strings.HasPrefix(name, "__substg1.0_") {
path := entry.Path // Path is array of directory names
// Root properties
if len(path) == 0 { // In root
val, err := io.ReadAll(doc)
if err != nil {
continue
}
processRootProperty(name, val, email)
} else if strings.HasPrefix(path[len(path)-1], "__attach_version1.0_") {
// Attachment property
attachStorageName := path[len(path)-1]
if _, exists := attachmentsMap[attachStorageName]; !exists {
attachmentsMap[attachStorageName] = &EmailAttachment{}
}
val, err := io.ReadAll(doc)
if err != nil {
continue
}
processAttachProperty(name, val, attachmentsMap[attachStorageName])
}
}
}
// Finalize attachments
for _, att := range attachmentsMap {
if strings.Contains(strings.ToLower(att.ContentType), "multipart/signed") {
dataStr := string(att.Data)
// Check if it already looks like a plain text EML (contains typical headers)
if strings.Contains(dataStr, "Content-Type:") || strings.Contains(dataStr, "MIME-Version:") || strings.Contains(dataStr, "From:") {
if !strings.HasSuffix(strings.ToLower(att.Filename), ".eml") {
att.Filename += ".eml"
}
} else {
// Try to decode as Base64
// Clean up the base64 string: remove newlines and spaces
base64Str := strings.Map(func(r rune) rune {
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
return -1
}
return r
}, dataStr)
// Try standard decoding
decoded, err := base64.StdEncoding.DecodeString(base64Str)
if err != nil {
// Try raw decoding (no padding)
decoded, err = base64.RawStdEncoding.DecodeString(base64Str)
}
if err == nil {
att.Data = decoded
if !strings.HasSuffix(strings.ToLower(att.Filename), ".eml") {
att.Filename += ".eml"
}
} else {
fmt.Println("Failed to decode multipart/signed attachment:", err)
}
}
}
if att.Filename == "" {
att.Filename = "attachment"
}
// Only add if we have data
if len(att.Data) > 0 {
email.Attachments = append(email.Attachments, *att)
}
}
return email, nil
}
func processRootProperty(name string, data []byte, email *EmailData) {
tag := name[12:16]
typ := name[16:20]
strVal := ""
if typ == ptUnicode {
strVal = decodeUTF16(data)
} else if typ == ptString8 {
strVal = string(data)
}
switch tag {
case prSubject:
email.Subject = strVal
case prBody:
if email.Body == "" { // Prefer body if not set
email.Body = strVal
}
case prBodyHTML:
email.Body = strVal // Prefer HTML
case prSenderName:
if email.From == "" {
email.From = strVal
} else {
email.From = fmt.Sprintf("%s <%s>", strVal, email.From)
}
case prSenderEmail:
if email.From == "" {
email.From = strVal
} else if !strings.Contains(email.From, "<") {
email.From = fmt.Sprintf("%s <%s>", email.From, strVal)
}
case prDisplayTo:
// Split by ; or similar if needed, but display string is usually one line
email.To = splitAndTrim(strVal)
case prDisplayCc:
email.Cc = splitAndTrim(strVal)
case prDisplayBcc:
email.Bcc = splitAndTrim(strVal)
case prClientSubmitTime:
// Date logic to be added if struct supports it
}
/*
if tag == prClientSubmitTime && typ == "0040" {
if len(data) >= 8 {
ft := binary.LittleEndian.Uint64(data)
t := time.Date(1601, 1, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(ft) * 100 * time.Nanosecond)
email.Date = t.Format(time.RFC1123Z)
}
}
*/
}
func processAttachProperty(name string, data []byte, att *EmailAttachment) {
tag := name[12:16]
typ := name[16:20]
strVal := ""
if typ == ptUnicode {
strVal = decodeUTF16(data)
} else if typ == ptString8 {
strVal = string(data)
}
switch tag {
case prAttachLongFilename:
att.Filename = strVal
case prAttachFilename:
if att.Filename == "" {
att.Filename = strVal
}
case prAttachMimeTag:
att.ContentType = strVal
case prAttachData:
att.Data = data
}
}
func decodeUTF16(b []byte) string {
decoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder()
decoded, _, _ := transform.Bytes(decoder, b)
// Remove null terminators if present
return strings.TrimRight(string(decoded), "\x00")
}
func splitAndTrim(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ";")
var res []string
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
res = append(res, t)
}
}
return res
}

View File

@@ -1,148 +1,741 @@
package internal package internal
import ( import (
"bufio"
"bytes"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/binary"
"errors"
"fmt" "fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/textproto"
"os" "os"
"os/exec"
"path/filepath"
"strings" "strings"
"time" "unicode/utf16"
) )
// ReadMsgFile reads a .msg file using the native Go parser. const (
func ReadMsgFile(filePath string) (*EmailData, error) { cfbSignature = 0xE11AB1A1E011CFD0
return ReadMsgPecFile(filePath) miniStreamCutoff = 4096
maxRegularSector = 0xFFFFFFFA
noStream = 0xFFFFFFFF
difatInHeader = 109
directoryEntrySize = 128
)
const (
pidTagSubject = 0x0037
pidTagConversationTopic = 0x0070
pidTagMessageClass = 0x001A
pidTagBody = 0x1000
pidTagBodyHTML = 0x1013
pidTagSenderName = 0x0C1A
pidTagSenderEmailAddress = 0x0C1F
pidTagSentRepresentingName = 0x0042
pidTagSentRepresentingAddr = 0x0065
pidTagDisplayTo = 0x0E04
pidTagDisplayCc = 0x0E03
pidTagDisplayBcc = 0x0E02
pidTagAttachFilename = 0x3704
pidTagAttachLongFilename = 0x3707
pidTagAttachData = 0x3701
pidTagAttachMimeTag = 0x370E
propTypeString8 = 0x001E
propTypeString = 0x001F
propTypeBinary = 0x0102
)
type cfbHeader struct {
Signature uint64
CLSID [16]byte
MinorVersion uint16
MajorVersion uint16
ByteOrder uint16
SectorShift uint16
MiniSectorShift uint16
Reserved1 [6]byte
TotalSectors uint32
FATSectors uint32
FirstDirectorySector uint32
TransactionSignature uint32
MiniStreamCutoff uint32
FirstMiniFATSector uint32
MiniFATSectors uint32
FirstDIFATSector uint32
DIFATSectors uint32
DIFAT [109]uint32
} }
func OSSReadMsgFile(filePath string) (*EmailData, error) { type directoryEntry struct {
return parseMsgFile(filePath) Name [64]byte
NameLen uint16
ObjectType uint8
ColorFlag uint8
LeftSiblingID uint32
RightSiblingID uint32
ChildID uint32
CLSID [16]byte
StateBits uint32
CreationTime uint64
ModifiedTime uint64
StartingSectorLoc uint32
StreamSize uint64
} }
// parseSignedMsgExec executes 'signed_msg.exe' via cmd to convert a MSG to JSON, type dirNode struct {
// then processes the output to reconstruct the PEC email data. Index int
func ReadMsgPecFile(filePath string) (*EmailData, error) { Entry directoryEntry
fmt.Println("Called!") Name string
// 1. Locate signed_msg.exe Children []*dirNode
exePath, err := os.Executable() }
type cfbReader struct {
reader io.ReaderAt
header cfbHeader
sectorSize int
fat []uint32
miniFAT []uint32
dirEntries []directoryEntry
root *dirNode
nodesByIdx map[int]*dirNode
miniStream []byte
}
func ReadMsgFile(path string) (*EmailData, error) {
f, err := os.Open(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get executable path: %w", err) return nil, err
} }
baseDir := filepath.Dir(exePath) defer func(f *os.File) {
helperExe := filepath.Join(baseDir, "signed_msg.exe") _ = f.Close()
}(f)
fmt.Println(helperExe) _, err = f.Stat()
// 2. Create temp file for JSON output
// Using generic temp file naming with timestamp
timestamp := time.Now().Format("20060102_150405")
tempFile, err := os.CreateTemp("", fmt.Sprintf("pec_output_%s_*.json", timestamp))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err) return nil, err
} }
tempPath := tempFile.Name() return Read(f)
tempFile.Close() // Close immediately, exe will write to it }
// defer os.Remove(tempPath) // Cleanup
// 3. Run signed_msg.exe <msgPath> <jsonPath> func Read(r io.ReaderAt) (*EmailData, error) {
// Use exec.Command cfb, err := newCFBReader(r)
// Note: Command might need to be "cmd", "/C", ... but usually direct execution works on Windows
fmt.Println(helperExe, filePath, tempPath)
cmd := exec.Command(helperExe, filePath, tempPath)
// Hide window?
// cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} // Requires syscall import
output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return nil, fmt.Errorf("signed_msg.exe failed: %s, output: %s", err, string(output)) return nil, err
}
return parseMessage(cfb)
}
func newCFBReader(r io.ReaderAt) (*cfbReader, error) {
cfb := &cfbReader{reader: r, nodesByIdx: make(map[int]*dirNode)}
headerData := make([]byte, 512)
if _, err := r.ReadAt(headerData, 0); err != nil {
return nil, err
} }
// 4. Read JSON output buf := bytes.NewReader(headerData)
jsonData, err := os.ReadFile(tempPath) if err := binary.Read(buf, binary.LittleEndian, &cfb.header); err != nil {
if err != nil { return nil, err
return nil, fmt.Errorf("failed to read json output: %w", err)
} }
// 5. Parse JSON if cfb.header.Signature != cfbSignature {
var pecJson struct { return nil, errors.New("invalid MSG file")
Subject string `json:"subject"`
From string `json:"from"`
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Body string `json:"body"`
HtmlBody string `json:"htmlBody"`
Attachments []struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
Data string `json:"data"` // Base64
DataFormat string `json:"dataFormat"` // "base64" (optional)
} `json:"attachments"`
} }
if err := json.Unmarshal(jsonData, &pecJson); err != nil { cfb.sectorSize = 1 << cfb.header.SectorShift
return nil, fmt.Errorf("failed to parse json output: %w", err)
if err := cfb.readFAT(); err != nil {
return nil, err
}
if err := cfb.readDirectories(); err != nil {
return nil, err
}
cfb.buildTree()
if cfb.header.FirstMiniFATSector < maxRegularSector {
_ = cfb.readMiniFAT()
}
if len(cfb.dirEntries) > 0 && cfb.dirEntries[0].StreamSize > 0 {
_ = cfb.readMiniStream()
} }
// 6. Check for postacert.eml to determine if it is a PEC return cfb, nil
var foundPostacert bool }
var hasDatiCert, hasSmime bool
// We'll prepare attachments listing at the same time func (cfb *cfbReader) sectorOffset(sector uint32) int64 {
var attachments []EmailAttachment return int64(sector+1) * int64(cfb.sectorSize)
}
for _, att := range pecJson.Attachments { func (cfb *cfbReader) readSector(sector uint32) ([]byte, error) {
attData, err := base64.StdEncoding.DecodeString(att.Data) data := make([]byte, cfb.sectorSize)
_, err := cfb.reader.ReadAt(data, cfb.sectorOffset(sector))
return data, err
}
func (cfb *cfbReader) readFAT() error {
var fatSectors []uint32
for i := 0; i < difatInHeader && i < int(cfb.header.FATSectors); i++ {
if cfb.header.DIFAT[i] < maxRegularSector {
fatSectors = append(fatSectors, cfb.header.DIFAT[i])
}
}
if cfb.header.DIFATSectors > 0 && cfb.header.FirstDIFATSector < maxRegularSector {
difatSector := cfb.header.FirstDIFATSector
for i := uint32(0); i < cfb.header.DIFATSectors && difatSector < maxRegularSector; i++ {
data, err := cfb.readSector(difatSector)
if err != nil {
return err
}
entriesPerSector := cfb.sectorSize/4 - 1
for j := 0; j < entriesPerSector && len(fatSectors) < int(cfb.header.FATSectors); j++ {
sector := binary.LittleEndian.Uint32(data[j*4:])
if sector < maxRegularSector {
fatSectors = append(fatSectors, sector)
}
}
difatSector = binary.LittleEndian.Uint32(data[entriesPerSector*4:])
}
}
entriesPerSector := cfb.sectorSize / 4
cfb.fat = make([]uint32, 0, len(fatSectors)*entriesPerSector)
for _, sector := range fatSectors {
data, err := cfb.readSector(sector)
if err != nil { if err != nil {
fmt.Printf("Failed to decode attachment %s: %v\n", att.Filename, err) return err
}
for i := 0; i < entriesPerSector; i++ {
cfb.fat = append(cfb.fat, binary.LittleEndian.Uint32(data[i*4:]))
}
}
return nil
}
func (cfb *cfbReader) readDirectories() error {
entriesPerSector := cfb.sectorSize / directoryEntrySize
sector := cfb.header.FirstDirectorySector
for sector < maxRegularSector {
data, err := cfb.readSector(sector)
if err != nil {
return err
}
for i := 0; i < entriesPerSector; i++ {
var entry directoryEntry
buf := bytes.NewReader(data[i*directoryEntrySize:])
if err := binary.Read(buf, binary.LittleEndian, &entry); err != nil {
return err
}
cfb.dirEntries = append(cfb.dirEntries, entry)
}
if int(sector) >= len(cfb.fat) {
break
}
sector = cfb.fat[sector]
}
return nil
}
func (cfb *cfbReader) buildTree() {
for i := range cfb.dirEntries {
entry := &cfb.dirEntries[i]
if entry.ObjectType == 0 {
continue
}
node := &dirNode{Index: i, Entry: *entry, Name: getDirName(entry)}
cfb.nodesByIdx[i] = node
}
if node, ok := cfb.nodesByIdx[0]; ok {
cfb.root = node
}
for _, node := range cfb.nodesByIdx {
if node.Entry.ChildID != noStream {
cfb.collectChildren(node, int(node.Entry.ChildID))
}
}
}
func (cfb *cfbReader) collectChildren(parent *dirNode, startIdx int) {
var traverse func(idx int)
traverse = func(idx int) {
if idx < 0 || idx >= len(cfb.dirEntries) || uint32(idx) == noStream {
return
}
child, ok := cfb.nodesByIdx[idx]
if !ok {
return
}
if child.Entry.LeftSiblingID != noStream {
traverse(int(child.Entry.LeftSiblingID))
}
parent.Children = append(parent.Children, child)
if child.Entry.RightSiblingID != noStream {
traverse(int(child.Entry.RightSiblingID))
}
}
traverse(startIdx)
}
func (cfb *cfbReader) readMiniFAT() error {
entriesPerSector := cfb.sectorSize / 4
sector := cfb.header.FirstMiniFATSector
for sector < maxRegularSector {
data, err := cfb.readSector(sector)
if err != nil {
return err
}
for i := 0; i < entriesPerSector; i++ {
cfb.miniFAT = append(cfb.miniFAT, binary.LittleEndian.Uint32(data[i*4:]))
}
if int(sector) >= len(cfb.fat) {
break
}
sector = cfb.fat[sector]
}
return nil
}
func (cfb *cfbReader) readMiniStream() error {
root := cfb.dirEntries[0]
cfb.miniStream = make([]byte, 0, root.StreamSize)
sector := root.StartingSectorLoc
remaining := int64(root.StreamSize)
for sector < maxRegularSector && remaining > 0 {
data, err := cfb.readSector(sector)
if err != nil {
return err
}
toRead := int64(cfb.sectorSize)
if toRead > remaining {
toRead = remaining
}
cfb.miniStream = append(cfb.miniStream, data[:toRead]...)
remaining -= toRead
if int(sector) >= len(cfb.fat) {
break
}
sector = cfb.fat[sector]
}
return nil
}
func (cfb *cfbReader) readStream(entry *directoryEntry) ([]byte, error) {
if entry.StreamSize == 0 {
return nil, nil
}
if entry.StreamSize < miniStreamCutoff {
return cfb.readMiniStreamData(entry)
}
return cfb.readRegularStream(entry)
}
func (cfb *cfbReader) readMiniStreamData(entry *directoryEntry) ([]byte, error) {
miniSectorSize := 1 << cfb.header.MiniSectorShift
data := make([]byte, 0, entry.StreamSize)
sector := entry.StartingSectorLoc
remaining := int64(entry.StreamSize)
for sector < maxRegularSector && remaining > 0 {
offset := int(sector) * miniSectorSize
if offset >= len(cfb.miniStream) {
break
}
toRead := miniSectorSize
if int64(toRead) > remaining {
toRead = int(remaining)
}
end := offset + toRead
if end > len(cfb.miniStream) {
end = len(cfb.miniStream)
}
data = append(data, cfb.miniStream[offset:end]...)
remaining -= int64(toRead)
if int(sector) >= len(cfb.miniFAT) {
break
}
sector = cfb.miniFAT[sector]
}
return data, nil
}
func (cfb *cfbReader) readRegularStream(entry *directoryEntry) ([]byte, error) {
data := make([]byte, 0, entry.StreamSize)
sector := entry.StartingSectorLoc
remaining := int64(entry.StreamSize)
for sector < maxRegularSector && remaining > 0 {
sectorData, err := cfb.readSector(sector)
if err != nil {
return nil, err
}
toRead := int64(cfb.sectorSize)
if toRead > remaining {
toRead = remaining
}
data = append(data, sectorData[:toRead]...)
remaining -= toRead
if int(sector) >= len(cfb.fat) {
break
}
sector = cfb.fat[sector]
}
return data, nil
}
func (cfb *cfbReader) readNodeStream(node *dirNode) ([]byte, error) {
return cfb.readStream(&node.Entry)
}
func getDirName(entry *directoryEntry) string {
if entry.NameLen <= 2 {
return ""
}
nameBytes := entry.Name[:entry.NameLen-2]
runes := make([]rune, 0, len(nameBytes)/2)
for i := 0; i < len(nameBytes); i += 2 {
r := rune(binary.LittleEndian.Uint16(nameBytes[i:]))
if r != 0 {
runes = append(runes, r)
}
}
return string(runes)
}
func parseMessage(cfb *cfbReader) (*EmailData, error) {
if cfb.root == nil {
return nil, errors.New("no root directory")
}
props := make(map[uint32][]byte)
for _, child := range cfb.root.Children {
if child.Entry.ObjectType == 2 && strings.HasPrefix(child.Name, "__substg1.0_") {
propID, propType := parsePropertyName(child.Name)
if propID != 0 {
data, _ := cfb.readNodeStream(child)
if data != nil {
props[(propID<<16)|uint32(propType)] = data
}
}
}
}
email := &EmailData{}
email.Subject = getPropString(props, pidTagSubject)
if email.Subject == "" {
email.Subject = getPropString(props, pidTagConversationTopic)
}
email.Body = getPropString(props, pidTagBodyHTML)
if email.Body == "" {
email.Body = getPropBinary(props, pidTagBodyHTML)
}
if email.Body == "" {
email.Body = textToHTML(getPropString(props, pidTagBody))
}
from := getPropString(props, pidTagSenderName)
if from == "" {
from = getPropString(props, pidTagSentRepresentingName)
}
fromEmail := getPropString(props, pidTagSenderEmailAddress)
if fromEmail == "" {
fromEmail = getPropString(props, pidTagSentRepresentingAddr)
}
if fromEmail != "" {
email.From = fmt.Sprintf("%s <%s>", from, fromEmail)
} else {
email.From = from
}
email.To = splitRecipients(getPropString(props, pidTagDisplayTo))
email.Cc = splitRecipients(getPropString(props, pidTagDisplayCc))
email.Bcc = splitRecipients(getPropString(props, pidTagDisplayBcc))
msgClass := getPropString(props, pidTagMessageClass)
email.IsPec = strings.Contains(strings.ToLower(msgClass), "smime") ||
strings.Contains(strings.ToLower(email.Subject), "posta certificata")
for _, child := range cfb.root.Children {
if strings.HasPrefix(child.Name, "__attach_version1.0_#") {
att := parseAttachment(cfb, child)
if att != nil {
if strings.HasPrefix(att.ContentType, "multipart/") {
innerAtts := extractMIMEAttachments(att.Data)
if len(innerAtts) > 0 {
email.HasInnerEmail = true
email.Attachments = append(email.Attachments, innerAtts...)
}
} else {
email.Attachments = append(email.Attachments, *att)
}
}
}
}
return email, nil
}
func parsePropertyName(name string) (uint32, uint16) {
if len(name) < 20 {
return 0, 0
}
hexPart := name[12:]
if len(hexPart) < 8 {
return 0, 0
}
var propID uint32
var propType uint16
_, _ = fmt.Sscanf(hexPart[:4], "%04X", &propID)
_, _ = fmt.Sscanf(hexPart[4:8], "%04X", &propType)
return propID, propType
}
func getPropString(props map[uint32][]byte, propID uint32) string {
if data, ok := props[(propID<<16)|propTypeString]; ok {
return decodeUTF16(data)
}
if data, ok := props[(propID<<16)|propTypeString8]; ok {
return strings.TrimRight(string(data), "\x00")
}
return ""
}
func getPropBinary(props map[uint32][]byte, propID uint32) string {
if data, ok := props[(propID<<16)|propTypeBinary]; ok {
return string(data)
}
return ""
}
func textToHTML(text string) string {
if text == "" {
return ""
}
text = strings.ReplaceAll(text, "&", "&amp;")
text = strings.ReplaceAll(text, "<", "&lt;")
text = strings.ReplaceAll(text, ">", "&gt;")
text = strings.ReplaceAll(text, "\r\n", "<br>")
text = strings.ReplaceAll(text, "\n", "<br>")
return text
}
func decodeUTF16(data []byte) string {
if len(data) < 2 {
return ""
}
u16s := make([]uint16, len(data)/2)
for i := range u16s {
u16s[i] = binary.LittleEndian.Uint16(data[i*2:])
}
for len(u16s) > 0 && u16s[len(u16s)-1] == 0 {
u16s = u16s[:len(u16s)-1]
}
return string(utf16.Decode(u16s))
}
func splitRecipients(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ";")
var result []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}
func parseAttachment(cfb *cfbReader, node *dirNode) *EmailAttachment {
att := &EmailAttachment{}
for _, child := range node.Children {
if child.Entry.ObjectType != 2 || !strings.HasPrefix(child.Name, "__substg1.0_") {
continue
}
propID, propType := parsePropertyName(child.Name)
data, _ := cfb.readNodeStream(child)
if data == nil {
continue continue
} }
filenameLower := strings.ToLower(att.Filename) switch propID {
if filenameLower == "postacert.eml" { case pidTagAttachLongFilename:
foundPostacert = true if fn := decodePropertyString(data, propType); fn != "" {
att.Filename = fn
}
case pidTagAttachFilename:
if att.Filename == "" {
att.Filename = decodePropertyString(data, propType)
}
case pidTagAttachMimeTag:
att.ContentType = decodePropertyString(data, propType)
case pidTagAttachData:
att.Data = data
} }
if filenameLower == "daticert.xml" {
hasDatiCert = true
}
if filenameLower == "smime.p7s" {
hasSmime = true
}
attachments = append(attachments, EmailAttachment{
Filename: att.Filename,
ContentType: att.ContentType,
Data: attData,
})
} }
if !foundPostacert { if att.Filename == "" && att.Data == nil {
// Maybe its a normal MSG, continue to try to parse it as a regular email return nil
normalMsgEmail, err := parseMsgFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to find postacert.eml and also failed to parse as normal MSG: %w", err)
}
return normalMsgEmail, nil
} }
return att
// 7. It is a PEC. Return the outer message (wrapper) }
// so the user can see the PEC envelope and attachments (postacert.eml, etc.)
func decodePropertyString(data []byte, propType uint16) string {
body := pecJson.HtmlBody switch propType {
if body == "" { case propTypeString:
body = pecJson.Body return decodeUTF16(data)
} case propTypeString8:
return strings.TrimRight(string(data), "\x00")
return &EmailData{ }
From: convertToUTF8(pecJson.From), return ""
To: pecJson.To, // Assuming format is already correct or compatible }
Cc: pecJson.Cc,
Bcc: pecJson.Bcc, func extractMIMEAttachments(data []byte) []EmailAttachment {
Subject: convertToUTF8(pecJson.Subject), data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
Body: convertToUTF8(body), data = bytes.ReplaceAll(data, []byte("\n"), []byte("\r\n"))
Attachments: attachments,
IsPec: hasDatiCert || hasSmime, // Typical PEC indicators reader := bufio.NewReader(bytes.NewReader(data))
HasInnerEmail: foundPostacert, tp := textproto.NewReader(reader)
}, nil headers, err := tp.ReadMIMEHeader()
if err != nil {
return nil
}
contentType := headers.Get("Content-Type")
mediaType, params, _ := mime.ParseMediaType(contentType)
if !strings.HasPrefix(mediaType, "multipart/") {
return nil
}
boundary := params["boundary"]
if boundary == "" {
return nil
}
body, _ := io.ReadAll(reader)
return parseMIMEParts(body, boundary)
}
func parseMIMEParts(body []byte, boundary string) []EmailAttachment {
var attachments []EmailAttachment
mr := multipart.NewReader(bytes.NewReader(body), boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
break
}
partBody, _ := io.ReadAll(part)
contentType := part.Header.Get("Content-Type")
mediaType, params, _ := mime.ParseMediaType(contentType)
encoding := part.Header.Get("Content-Transfer-Encoding")
if strings.HasPrefix(mediaType, "multipart/") {
if b := params["boundary"]; b != "" {
attachments = append(attachments, parseMIMEParts(partBody, b)...)
}
continue
}
if mediaType == "message/rfc822" {
filename := getFilename(part.Header, params)
if filename == "" {
filename = "email.eml"
}
attachments = append(attachments, EmailAttachment{
Filename: filename,
ContentType: "message/rfc822",
Data: partBody,
})
innerAtts := extractFromRFC822(partBody)
attachments = append(attachments, innerAtts...)
continue
}
filename := getFilename(part.Header, params)
if filename == "" && mediaType == "application/pkcs7-signature" {
filename = "smime.p7s"
}
if filename != "" {
decoded := decodeBody(partBody, encoding)
attachments = append(attachments, EmailAttachment{
Filename: filename,
ContentType: mediaType,
Data: decoded,
})
}
}
return attachments
}
func extractFromRFC822(data []byte) []EmailAttachment {
data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
data = bytes.ReplaceAll(data, []byte("\n"), []byte("\r\n"))
reader := bufio.NewReader(bytes.NewReader(data))
tp := textproto.NewReader(reader)
headers, err := tp.ReadMIMEHeader()
if err != nil {
return nil
}
contentType := headers.Get("Content-Type")
mediaType, params, _ := mime.ParseMediaType(contentType)
if !strings.HasPrefix(mediaType, "multipart/") {
return nil
}
boundary := params["boundary"]
if boundary == "" {
return nil
}
body, _ := io.ReadAll(reader)
return parseMIMEParts(body, boundary)
}
func getFilename(header textproto.MIMEHeader, params map[string]string) string {
if cd := header.Get("Content-Disposition"); cd != "" {
_, dispParams, _ := mime.ParseMediaType(cd)
if fn := dispParams["filename"]; fn != "" {
return fn
}
}
return params["name"]
}
func decodeBody(body []byte, encoding string) []byte {
switch strings.ToLower(encoding) {
case "base64":
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(body)))
n, err := base64.StdEncoding.Decode(decoded, bytes.TrimSpace(body))
if err == nil {
return decoded[:n]
}
case "quoted-printable":
decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(body)))
if err == nil {
return decoded
}
}
return body
} }

View File

@@ -1,6 +1,6 @@
[EMLy] [EMLy]
SDK_DECODER_SEMVER="1.2.1-hotfix_1" SDK_DECODER_SEMVER="1.3.0"
SDK_DECODER_RELEASE_CHANNEL="alpha" SDK_DECODER_RELEASE_CHANNEL="beta"
GUI_SEMVER="1.2.2" GUI_SEMVER="1.2.4"
GUI_RELEASE_CHANNEL="beta" GUI_RELEASE_CHANNEL="beta"
LANGUAGE="it" LANGUAGE="it"

3
frontend/.gitignore vendored
View File

@@ -25,3 +25,6 @@ vite.config.ts.timestamp-*
# Paraglide # Paraglide
src/lib/paraglide src/lib/paraglide
project.inlang/cache/ project.inlang/cache/
# Wails
/src/lib/wailsjs

View File

@@ -85,5 +85,9 @@
"mail_pec_signed_badge": "Signed mail", "mail_pec_signed_badge": "Signed mail",
"mail_pec_feature_warning": "PEC detected: some features may be limited.", "mail_pec_feature_warning": "PEC detected: some features may be limited.",
"mail_sign_label": "Sign:", "mail_sign_label": "Sign:",
"mail_loading_msg_conversion": "Converting MSG file... This might take a while." "mail_loading_msg_conversion": "Converting MSG file... This might take a while.",
"mail_pdf_already_open": "The PDF is already open in another window.",
"settings_danger_debugger_protection_label": "Enable attached debugger protection",
"settings_danger_debugger_protection_hint": "This will prevent the app from being debugged by an attached debugger.",
"settings_danger_debugger_protection_info": "Info: This actions are currently not configurable and is always enabled for private builds."
} }

View File

@@ -34,7 +34,7 @@
"settings_msg_converter_description": "Configura come vengono elaborati i file MSG.", "settings_msg_converter_description": "Configura come vengono elaborati i file MSG.",
"settings_msg_converter_label": "Usa convertitore MSG in EML", "settings_msg_converter_label": "Usa convertitore MSG in EML",
"settings_msg_converter_hint": "Usa un'applicazione esterna per convertire i file .MSG in .EML. Disabilitalo per usare la versione OSS (meno accurata).", "settings_msg_converter_hint": "Usa un'applicazione esterna per convertire i file .MSG in .EML. Disabilitalo per usare la versione OSS (meno accurata).",
"settings_danger_zone_title": "Zona Pericolo", "settings_danger_zone_title": "Zona Pericolosa",
"settings_danger_zone_description": "Azioni avanzate. Procedere con cautela.", "settings_danger_zone_description": "Azioni avanzate. Procedere con cautela.",
"settings_danger_devtools_label": "Apri DevTools", "settings_danger_devtools_label": "Apri DevTools",
"settings_danger_devtools_hint": "A causa di limitazioni del framework, i DevTools devono essere aperti manualmente. Per aprire i DevTools, premi Ctrl+Shift+F12.", "settings_danger_devtools_hint": "A causa di limitazioni del framework, i DevTools devono essere aperti manualmente. Per aprire i DevTools, premi Ctrl+Shift+F12.",
@@ -85,5 +85,9 @@
"mail_pec_signed_badge": "Mail firmata", "mail_pec_signed_badge": "Mail firmata",
"mail_pec_feature_warning": "PEC rilevata: alcune funzionalità potrebbero essere limitate.", "mail_pec_feature_warning": "PEC rilevata: alcune funzionalità potrebbero essere limitate.",
"mail_sign_label": "Firma:", "mail_sign_label": "Firma:",
"mail_loading_msg_conversion": "Conversione file MSG in corso... Potrebbe richiedere del tempo." "mail_loading_msg_conversion": "Conversione file MSG in corso... Potrebbe richiedere del tempo.",
"mail_pdf_already_open": "Il file PDF è già aperto in una finestra separata.",
"settings_danger_debugger_protection_label": "Abilita protezione da debugger",
"settings_danger_debugger_protection_hint": "Questo impedirà che il debug dell'app venga eseguito da un debugger collegato.",
"settings_danger_debugger_protection_info": "Info: Questa azione non è attualmente configurabile ed è sempre abilitata per le build private."
} }

View File

@@ -1 +1 @@
80dcc9e81c52df44518195ddfd550d26 3c4a64d0cfb34e86fac16fceae842e43

View File

@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import { X, MailOpen, Image, FileText, File, ShieldCheck, Shield, Signature, FileUser, Loader2 } from "@lucide/svelte"; import { X, MailOpen, Image, FileText, File, ShieldCheck, Shield, Signature, FileCode, Loader2 } from "@lucide/svelte";
import { ShowOpenFileDialog, ReadEML, OpenPDF, OpenImageWindow, OpenPDFWindow, OpenImage, ReadMSG, ReadPEC, OpenEMLWindow } from "$lib/wailsjs/go/main/App"; import { ShowOpenFileDialog, ReadEML, OpenPDF, OpenImageWindow, OpenPDFWindow, OpenImage, ReadMSG, ReadPEC, OpenEMLWindow } from "$lib/wailsjs/go/main/App";
import type { internal } from "$lib/wailsjs/go/models"; import type { internal } from "$lib/wailsjs/go/models";
import { sidebarOpen } from "$lib/stores/app"; import { sidebarOpen } from "$lib/stores/app";
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { EventsOn, WindowShow, WindowUnminimise } from "$lib/wailsjs/runtime/runtime"; import { EventsOn, WindowShow, WindowUnminimise } from "$lib/wailsjs/runtime/runtime";
import type { SupportedFileTypePreview } from "$lib/types";
import { mailState } from "$lib/stores/mail-state.svelte"; import { mailState } from "$lib/stores/mail-state.svelte";
import { settingsStore } from "$lib/stores/settings.svelte"; import { settingsStore } from "$lib/stores/settings.svelte";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
@@ -15,6 +14,9 @@
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>";
function onClear() { function onClear() {
mailState.clear(); mailState.clear();
} }
@@ -22,7 +24,7 @@
$effect(() => { $effect(() => {
console.log("Current email changed:", mailState.currentEmail); console.log("Current email changed:", mailState.currentEmail);
if(mailState.currentEmail !== null) { if(mailState.currentEmail !== null) {
sidebarOpen.set(false); sidebarOpen.set(false);
} }
console.log(mailState.currentEmail?.attachments) console.log(mailState.currentEmail?.attachments)
}) })
@@ -47,21 +49,12 @@
let emlContent; let emlContent;
if (lowerArg.endsWith(".msg")) { if (lowerArg.endsWith(".msg")) {
const useExt = settingsStore.settings.useMsgConverter ?? true; loadingText = m.mail_loading_msg_conversion();
if (useExt) { emlContent = await ReadMSG(arg, true);
loadingText = m.mail_loading_msg_conversion();
}
emlContent = await ReadMSG(arg, useExt);
if(emlContent.isPec) {
toast.warning(m.mail_pec_feature_warning());
}
} else { } else {
// EML handling // EML handling
try { try {
emlContent = await ReadPEC(arg); emlContent = await ReadPEC(arg);
if(emlContent.isPec) {
toast.warning(m.mail_pec_feature_warning());
}
} catch (e) { } catch (e) {
console.warn("ReadPEC failed, trying ReadEML:", e); console.warn("ReadPEC failed, trying ReadEML:", e);
emlContent = await ReadEML(arg); emlContent = await ReadEML(arg);
@@ -103,9 +96,13 @@
} else { } else {
await OpenPDF(base64Data, filename); await OpenPDF(base64Data, filename);
} }
} catch (error) { } 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); console.error("Failed to open PDF:", error);
toast.error(m.mail_error_pdf()); toast.error(m.mail_error_pdf());
} }
} }
@@ -141,17 +138,11 @@
// If the file is .eml, otherwise if is .msg, read accordingly // If the file is .eml, otherwise if is .msg, read accordingly
let email: internal.EmailData; let email: internal.EmailData;
if(result.toLowerCase().endsWith(".msg")) { if(result.toLowerCase().endsWith(".msg")) {
const useExt = settingsStore.settings.useMsgConverter ?? true; loadingText = m.mail_loading_msg_conversion();
if (useExt) { email = await ReadMSG(result, true);
loadingText = m.mail_loading_msg_conversion();
}
email = await ReadMSG(result, useExt);
} else { } else {
email = await ReadEML(result); email = await ReadEML(result);
} }
if(email.isPec) {
toast.warning(m.mail_pec_feature_warning(), {duration: 10000});
}
mailState.setParams(email); mailState.setParams(email);
sidebarOpen.set(false); sidebarOpen.set(false);
@@ -182,15 +173,10 @@
return ""; return "";
} }
function getFileExtension(filename: string): string { function handleWheel(event: WheelEvent) {
return filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase(); if (event.ctrlKey) {
} event.preventDefault();
}
function shouldPreview(filename: string): boolean {
if (!settingsStore.settings.useBuiltinPreview) return false;
const ext = getFileExtension(filename);
const supported = settingsStore.settings.previewFileSupportedTypes || [];
return supported.includes(ext as SupportedFileTypePreview);
} }
</script> </script>
@@ -290,7 +276,7 @@
class="att-btn pdf" class="att-btn pdf"
onclick={() => openPDFHandler(arrayBufferToBase64(att.data), att.filename)} onclick={() => openPDFHandler(arrayBufferToBase64(att.data), att.filename)}
> >
<FileText size="15" /> <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 att.filename.toLowerCase().endsWith(".eml")}
@@ -316,7 +302,7 @@
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`} href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`}
download={att.filename} download={att.filename}
> >
<FileUser size="14" /> <FileCode size="14" />
<span class="att-name">{att.filename}</span> <span class="att-name">{att.filename}</span>
</a> </a>
{:else} {:else}
@@ -342,11 +328,11 @@
<div class="email-body-wrapper"> <div class="email-body-wrapper">
<iframe <iframe
srcdoc={mailState.currentEmail.body + srcdoc={mailState.currentEmail.body + iFrameUtilHTML}
"<style>body{margin:0;padding:20px;font-family:sans-serif;} a{pointer-events:none!important;cursor:default!important;}</style>"}
title="Email Body" title="Email Body"
class="email-iframe" class="email-iframe"
sandbox="allow-same-origin" sandbox="allow-same-origin allow-scripts"
onwheel={handleWheel}
></iframe> ></iframe>
</div> </div>
</div> </div>
@@ -641,28 +627,6 @@
font-style: italic; font-style: italic;
} }
.badged-row {
display: flex;
gap: 8px;
align-items: center;
}
.signed-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(239, 68, 68, 0.15);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 2px 6px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
vertical-align: middle;
user-select: none;
width: fit-content;
}
.pec-badge { .pec-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -5,9 +5,11 @@ import { getFromLocalStorage, saveToLocalStorage } from "$lib/utils/localStorage
const STORAGE_KEY = "emly_gui_settings"; const STORAGE_KEY = "emly_gui_settings";
const defaults: EMLy_GUI_Settings = { const defaults: EMLy_GUI_Settings = {
selectedLanguage: "en", selectedLanguage: "it",
useBuiltinPreview: true, useBuiltinPreview: true,
useBuiltinPDFViewer: true,
previewFileSupportedTypes: ["jpg", "jpeg", "png"], previewFileSupportedTypes: ["jpg", "jpeg", "png"],
enableAttachedDebuggerProtection: true,
}; };
class SettingsStore { class SettingsStore {

View File

@@ -6,8 +6,8 @@ interface EMLy_GUI_Settings {
selectedLanguage: SupportedLanguages = "en" | "it"; selectedLanguage: SupportedLanguages = "en" | "it";
useBuiltinPreview: boolean; useBuiltinPreview: boolean;
useBuiltinPDFViewer?: boolean; useBuiltinPDFViewer?: boolean;
useMsgConverter?: boolean;
previewFileSupportedTypes?: SupportedFileTypePreview[]; previewFileSupportedTypes?: SupportedFileTypePreview[];
enableAttachedDebuggerProtection?: boolean;
} }
type SupportedLanguages = "en" | "it"; type SupportedLanguages = "en" | "it";

View File

@@ -18,6 +18,8 @@ export function GetStartupFile():Promise<string>;
export function GetViewerData():Promise<main.ViewerData>; export function GetViewerData():Promise<main.ViewerData>;
export function IsDebuggerRunning():Promise<boolean>;
export function OpenDefaultAppsSettings():Promise<void>; export function OpenDefaultAppsSettings():Promise<void>;
export function OpenEMLWindow(arg1:string,arg2:string):Promise<void>; export function OpenEMLWindow(arg1:string,arg2:string):Promise<void>;

View File

@@ -30,6 +30,10 @@ export function GetViewerData() {
return window['go']['main']['App']['GetViewerData'](); return window['go']['main']['App']['GetViewerData']();
} }
export function IsDebuggerRunning() {
return window['go']['main']['App']['IsDebuggerRunning']();
}
export function OpenDefaultAppsSettings() { export function OpenDefaultAppsSettings() {
return window['go']['main']['App']['OpenDefaultAppsSettings'](); return window['go']['main']['App']['OpenDefaultAppsSettings']();
} }

View File

@@ -11,7 +11,7 @@
import { Toaster } from "$lib/components/ui/sonner/index.js"; import { Toaster } from "$lib/components/ui/sonner/index.js";
import AppSidebar from "$lib/components/SidebarApp.svelte"; import AppSidebar from "$lib/components/SidebarApp.svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js"; import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { dev } from '$app/environment'; import { dev } from "$app/environment";
import { import {
PanelRightClose, PanelRightClose,
PanelRightOpen, PanelRightOpen,
@@ -30,9 +30,11 @@
Quit, Quit,
} from "$lib/wailsjs/runtime/runtime"; } from "$lib/wailsjs/runtime/runtime";
import { RefreshCcwDot } from "@lucide/svelte"; import { RefreshCcwDot } from "@lucide/svelte";
import { IsDebuggerRunning, QuitApp } from "$lib/wailsjs/go/main/App";
let versionInfo: utils.Config | null = $state(null); let versionInfo: utils.Config | null = $state(null);
let isMaximized = $state(false); let isMaximized = $state(false);
let isDebugerOn: boolean = $state(false);
async function syncMaxState() { async function syncMaxState() {
isMaximized = await WindowIsMaximised(); isMaximized = await WindowIsMaximised();
@@ -67,9 +69,31 @@
} }
onMount(async () => { onMount(async () => {
if (browser) {
detectDebugging();
setInterval(detectDebugging, 1000);
}
versionInfo = data.data as utils.Config; versionInfo = data.data as utils.Config;
}); });
function handleWheel(event: WheelEvent) {
if (event.ctrlKey) {
event.preventDefault();
}
}
async function detectDebugging() {
if (!browser) return;
if (isDebugerOn === true) return; // Prevent multiple detections
isDebugerOn = await IsDebuggerRunning();
if (isDebugerOn) {
if(dev) toast.warning("Debugger is attached.");
await new Promise((resolve) => setTimeout(resolve, 5000));
await QuitApp();
}
}
let { data, children } = $props(); let { data, children } = $props();
const THEME_KEY = "emly_theme"; const THEME_KEY = "emly_theme";
@@ -101,7 +125,7 @@
syncMaxState(); syncMaxState();
</script> </script>
<div class="app"> <div class="app" onwheel={handleWheel}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="titlebar" class="titlebar"
@@ -113,7 +137,8 @@
<div class="version-wrapper"> <div class="version-wrapper">
<version> <version>
{#if dev} {#if dev}
v{versionInfo?.EMLy.GUISemver}_{versionInfo?.EMLy.GUIReleaseChannel} <debug>(DEBUG BUILD)</debug> v{versionInfo?.EMLy.GUISemver}_{versionInfo?.EMLy.GUIReleaseChannel}
<debug>(DEBUG BUILD)</debug>
{:else} {:else}
v{versionInfo?.EMLy.GUISemver}_{versionInfo?.EMLy.GUIReleaseChannel} v{versionInfo?.EMLy.GUISemver}_{versionInfo?.EMLy.GUIReleaseChannel}
{/if} {/if}
@@ -123,12 +148,15 @@
<div class="tooltip-item"> <div class="tooltip-item">
<span class="label">GUI:</span> <span class="label">GUI:</span>
<span class="value">v{versionInfo.EMLy.GUISemver}</span> <span class="value">v{versionInfo.EMLy.GUISemver}</span>
<span class="channel">({versionInfo.EMLy.GUIReleaseChannel})</span> <span class="channel">({versionInfo.EMLy.GUIReleaseChannel})</span
>
</div> </div>
<div class="tooltip-item"> <div class="tooltip-item">
<span class="label">SDK:</span> <span class="label">SDK:</span>
<span class="value">v{versionInfo.EMLy.SDKDecoderSemver}</span> <span class="value">v{versionInfo.EMLy.SDKDecoderSemver}</span>
<span class="channel">({versionInfo.EMLy.SDKDecoderReleaseChannel})</span> <span class="channel"
>({versionInfo.EMLy.SDKDecoderReleaseChannel})</span
>
</div> </div>
</div> </div>
{/if} {/if}
@@ -294,7 +322,7 @@
opacity: 0.4; opacity: 0.4;
} }
.title version debug{ .title version debug {
color: #e11d48; color: #e11d48;
opacity: 1; opacity: 1;
font-weight: 600; font-weight: 600;

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import MailViewer from "$lib/components/dashboard/MailViewer.svelte"; import MailViewer from "$lib/components/MailViewer.svelte";
import { mailState } from "$lib/stores/mail-state.svelte"; import { mailState } from "$lib/stores/mail-state.svelte";
let { data } = $props(); let { data } = $props();

View File

@@ -2,7 +2,6 @@ import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import { GetViewerData, GetStartupFile, ReadEML, ReadMSG } from '$lib/wailsjs/go/main/App'; import { GetViewerData, GetStartupFile, ReadEML, ReadMSG } from '$lib/wailsjs/go/main/App';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { internal } from '$lib/wailsjs/go/models'; import type { internal } from '$lib/wailsjs/go/models';
export const load: PageLoad = async () => { export const load: PageLoad = async () => {
@@ -23,8 +22,7 @@ export const load: PageLoad = async () => {
let emlContent: internal.EmailData; let emlContent: internal.EmailData;
if (startupFile.toLowerCase().endsWith(".msg")) { if (startupFile.toLowerCase().endsWith(".msg")) {
const useExt = settingsStore.settings.useMsgConverter ?? true; emlContent = await ReadMSG(startupFile, true);
emlContent = await ReadMSG(startupFile, useExt);
} else { } else {
emlContent = await ReadEML(startupFile); emlContent = await ReadEML(startupFile);
} }

View File

@@ -6,7 +6,7 @@
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import { Switch } from "$lib/components/ui/switch"; import { Switch } from "$lib/components/ui/switch";
import { ChevronLeft, Command, Option, Flame } from "@lucide/svelte"; import { ChevronLeft, Flame } from "@lucide/svelte";
import type { EMLy_GUI_Settings } from "$lib/types"; import type { EMLy_GUI_Settings } from "$lib/types";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { It, Us } from "svelte-flags"; import { It, Us } from "svelte-flags";
@@ -24,16 +24,19 @@
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
import { setLocale } from "$lib/paraglide/runtime"; import { setLocale } from "$lib/paraglide/runtime";
import { mailState } from "$lib/stores/mail-state.svelte.js"; import { mailState } from "$lib/stores/mail-state.svelte.js";
import { dev } from '$app/environment';
let { data } = $props(); let { data } = $props();
let config = $derived(data.config); let config = $derived(data.config);
let runningInDevMode: boolean = dev || false;
const defaults: EMLy_GUI_Settings = { const defaults: EMLy_GUI_Settings = {
selectedLanguage: "it", selectedLanguage: "it",
useBuiltinPreview: true, useBuiltinPreview: true,
useBuiltinPDFViewer: true, useBuiltinPDFViewer: true,
useMsgConverter: true,
previewFileSupportedTypes: ["jpg", "jpeg", "png"], previewFileSupportedTypes: ["jpg", "jpeg", "png"],
enableAttachedDebuggerProtection: true,
}; };
async function setLanguage( async function setLanguage(
@@ -60,9 +63,10 @@
useBuiltinPreview: !!s.useBuiltinPreview, useBuiltinPreview: !!s.useBuiltinPreview,
useBuiltinPDFViewer: useBuiltinPDFViewer:
s.useBuiltinPDFViewer ?? defaults.useBuiltinPDFViewer ?? true, s.useBuiltinPDFViewer ?? defaults.useBuiltinPDFViewer ?? true,
useMsgConverter: s.useMsgConverter ?? defaults.useMsgConverter ?? true,
previewFileSupportedTypes: previewFileSupportedTypes:
s.previewFileSupportedTypes || defaults.previewFileSupportedTypes || [], s.previewFileSupportedTypes || defaults.previewFileSupportedTypes || [],
enableAttachedDebuggerProtection:
s.enableAttachedDebuggerProtection ?? defaults.enableAttachedDebuggerProtection ?? true,
}; };
} }
@@ -71,7 +75,7 @@
(a.selectedLanguage ?? "") === (b.selectedLanguage ?? "") && (a.selectedLanguage ?? "") === (b.selectedLanguage ?? "") &&
!!a.useBuiltinPreview === !!b.useBuiltinPreview && !!a.useBuiltinPreview === !!b.useBuiltinPreview &&
!!a.useBuiltinPDFViewer === !!b.useBuiltinPDFViewer && !!a.useBuiltinPDFViewer === !!b.useBuiltinPDFViewer &&
!!a.useMsgConverter === !!b.useMsgConverter && !!a.enableAttachedDebuggerProtection === !!b.enableAttachedDebuggerProtection &&
JSON.stringify(a.previewFileSupportedTypes?.sort()) === JSON.stringify(a.previewFileSupportedTypes?.sort()) ===
JSON.stringify(b.previewFileSupportedTypes?.sort()) JSON.stringify(b.previewFileSupportedTypes?.sort())
); );
@@ -366,36 +370,7 @@
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<Card.Root> {#if $dangerZoneEnabled || dev}
<Card.Header class="space-y-1">
<Card.Title>{m.settings_msg_converter_title()}</Card.Title>
<Card.Description
>{m.settings_msg_converter_description()}</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-3">
<div
class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4"
>
<div>
<Label class="text-base">
{m.settings_msg_converter_label()}
</Label>
<p class="text-sm text-muted-foreground">
{m.settings_msg_converter_hint()}
</p>
</div>
<Switch
bind:checked={form.useMsgConverter}
class="cursor-pointer hover:cursor-pointer"
/>
</div>
</div>
</Card.Content>
</Card.Root>
{#if $dangerZoneEnabled}
<Card.Root class="border-destructive/50 bg-destructive/15"> <Card.Root class="border-destructive/50 bg-destructive/15">
<Card.Header class="space-y-1"> <Card.Header class="space-y-1">
<Card.Title class="text-destructive" <Card.Title class="text-destructive"
@@ -490,6 +465,26 @@
</div> </div>
<Separator /> <Separator />
<div
class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4 border-destructive/30"
>
<div class="space-y-1">
<Label class="text-sm">{m.settings_danger_debugger_protection_label()}</Label>
<div class="text-sm text-muted-foreground">
{m.settings_danger_debugger_protection_hint()}
</div>
</div>
<Switch
bind:checked={form.enableAttachedDebuggerProtection}
class="cursor-pointer hover:cursor-pointer"
disabled={!runningInDevMode}
/>
</div>
<div class="text-xs text-muted-foreground">
<strong>{m.settings_danger_debugger_protection_info()}</strong>
</div>
<Separator />
<div class="text-xs text-muted-foreground"> <div class="text-xs text-muted-foreground">
GUI: {config GUI: {config
? `${config.GUISemver} (${config.GUIReleaseChannel})` ? `${config.GUISemver} (${config.GUIReleaseChannel})`

View File

@@ -37,10 +37,17 @@
toggleMaximize(); toggleMaximize();
} }
function handleWheel(event: WheelEvent) {
if (event.ctrlKey) {
event.preventDefault();
}
}
syncMaxState(); syncMaxState();
</script> </script>
<div class="app-layout"> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="app-layout" onwheel={handleWheel}>
<!-- Titlebar --> <!-- Titlebar -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div

View File

@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { PageData } from './$types'; import type { PageData } from "./$types";
import { import {
RotateCcw, RotateCcw,
RotateCw, RotateCw,
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
AlignHorizontalSpaceAround AlignHorizontalSpaceAround,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { sidebarOpen } from "$lib/stores/app"; import { sidebarOpen } from "$lib/stores/app";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import * as pdfjsLib from "pdfjs-dist"; import * as pdfjsLib from "pdfjs-dist";
import pdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import pdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?url";
if (typeof Promise.withResolvers === 'undefined') { if (typeof Promise.withResolvers === "undefined") {
// @ts-ignore // @ts-ignore
Promise.withResolvers = function () { Promise.withResolvers = function () {
let resolve, reject; let resolve, reject;
@@ -36,7 +36,7 @@
let scale = $state(1.5); // Default scale let scale = $state(1.5); // Default scale
let error = $state(""); let error = $state("");
let loading = $state(true); let loading = $state(true);
let pdfDoc = $state<pdfjsLib.PDFDocumentProxy | null>(null); let pdfDoc = $state<pdfjsLib.PDFDocumentProxy | null>(null);
let pageNum = $state(1); let pageNum = $state(1);
let totalPages = $state(0); let totalPages = $state(0);
@@ -53,7 +53,7 @@
const len = binaryString.length; const len = binaryString.length;
const bytes = new Uint8Array(len); const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i); bytes[i] = binaryString.charCodeAt(i);
} }
pdfData = bytes; pdfData = bytes;
filename = result.filename; filename = result.filename;
@@ -62,10 +62,10 @@
sidebarOpen.set(false); sidebarOpen.set(false);
await loadPDF(); await loadPDF();
} else { } else {
toast.error("No PDF data provided"); toast.error("No PDF data provided");
error = "No PDF data provided. Please open this window from the main EMLy application."; error =
"No PDF data provided. Please open this window from the main EMLy application.";
loading = false; loading = false;
} }
} catch (e) { } catch (e) {
@@ -76,29 +76,30 @@
async function loadPDF() { async function loadPDF() {
if (!pdfData) return; if (!pdfData) return;
// Set a timeout to prevent infinite loading // Set a timeout to prevent infinite loading
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (loading) { if (loading) {
loading = false; loading = false;
error = "Timeout loading PDF. The worker might have failed to initialize."; error =
toast.error(error); "Timeout loading PDF. The worker might have failed to initialize.";
} toast.error(error);
}
}, 10000); }, 10000);
try { try {
const loadingTask = pdfjsLib.getDocument({ data: pdfData }); const loadingTask = pdfjsLib.getDocument({ data: pdfData });
pdfDoc = await loadingTask.promise; pdfDoc = await loadingTask.promise;
totalPages = pdfDoc.numPages; totalPages = pdfDoc.numPages;
pageNum = 1; pageNum = 1;
await renderPage(pageNum); await renderPage(pageNum);
loading = false; loading = false;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
error = "Error parsing PDF: " + e; error = "Error parsing PDF: " + e;
loading = false; loading = false;
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
} }
@@ -106,36 +107,36 @@
if (!pdfDoc || !canvasRef) return; if (!pdfDoc || !canvasRef) return;
if (renderTask) { if (renderTask) {
await renderTask.promise.catch(() => {}); // Cancel previous render if any (though we wait usually) await renderTask.promise.catch(() => {}); // Cancel previous render if any (though we wait usually)
} }
try { try {
const page = await pdfDoc.getPage(num); const page = await pdfDoc.getPage(num);
// Calculate scale if needed or use current scale
// We apply rotation to the viewport
const viewport = page.getViewport({ scale: scale, rotation: rotation });
const canvas = canvasRef; // Calculate scale if needed or use current scale
const context = canvas.getContext('2d'); // We apply rotation to the viewport
const viewport = page.getViewport({ scale: scale, rotation: rotation });
if (!context) return; const canvas = canvasRef;
const context = canvas.getContext("2d");
canvas.height = viewport.height; if (!context) return;
canvas.width = viewport.width;
const renderContext = { canvas.height = viewport.height;
canvasContext: context, canvas.width = viewport.width;
viewport: viewport
}; const renderContext = {
canvasContext: context,
// Cast to any to avoid type mismatch with PDF.js definitions viewport: viewport,
await page.render(renderContext as any).promise; };
// Cast to any to avoid type mismatch with PDF.js definitions
await page.render(renderContext as any).promise;
} catch (e: any) { } catch (e: any) {
if (e.name !== 'RenderingCancelledException') { if (e.name !== "RenderingCancelledException") {
console.error(e); console.error(e);
toast.error("Error rendering page: " + e.message); toast.error("Error rendering page: " + e.message);
} }
} }
} }
@@ -143,11 +144,13 @@
if (!pdfDoc || !canvasContainerRef) return; if (!pdfDoc || !canvasContainerRef) return;
// We need to fetch page to get dimensions // We need to fetch page to get dimensions
loading = true; loading = true;
pdfDoc.getPage(pageNum).then(page => { pdfDoc.getPage(pageNum).then((page) => {
const containerWidth = canvasContainerRef!.clientWidth - 40; // padding const containerWidth = canvasContainerRef!.clientWidth - 40; // padding
const viewport = page.getViewport({ scale: 1, rotation: rotation }); const viewport = page.getViewport({ scale: 1, rotation: rotation });
scale = containerWidth / viewport.width; scale = containerWidth / viewport.width;
renderPage(pageNum).then(() => { loading = false; }); renderPage(pageNum).then(() => {
loading = false;
});
}); });
} }
@@ -157,7 +160,7 @@
const _deps = [scale, rotation]; const _deps = [scale, rotation];
if (pdfDoc) { if (pdfDoc) {
renderPage(pageNum); renderPage(pageNum);
} }
}); });
@@ -180,7 +183,6 @@
pageNum--; pageNum--;
renderPage(pageNum); renderPage(pageNum);
} }
</script> </script>
<div class="viewer-container"> <div class="viewer-container">
@@ -345,15 +347,15 @@
padding: 20px; padding: 20px;
background: #333; /* Dark background for contrast */ background: #333; /* Dark background for contrast */
} }
canvas { canvas {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
max-width: none; /* Allow canvas to be larger than container */ max-width: none; /* Allow canvas to be larger than container */
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 10px;
height: 6px; height: 10px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -372,4 +374,4 @@
::-webkit-scrollbar-corner { ::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }
</style> </style>

2
go.mod
View File

@@ -4,7 +4,6 @@ go 1.24.4
require ( require (
github.com/jaypipes/ghw v0.21.2 github.com/jaypipes/ghw v0.21.2
github.com/richardlehane/mscfb v1.0.6
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
golang.org/x/sys v0.40.0 golang.org/x/sys v0.40.0
golang.org/x/text v0.22.0 golang.org/x/text v0.22.0
@@ -29,7 +28,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/richardlehane/msoleps v1.0.3 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect

4
go.sum
View File

@@ -51,10 +51,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=

View File

@@ -1,42 +1,46 @@
#define ApplicationName 'EMLy'
#define ApplicationVersion GetVersionNumbersString('EMLy.exe')
#define ApplicationVersion '1.2.4_beta'
[Setup] [Setup]
AppName=EMLy AppName={#ApplicationName}
AppVersion=1.2.2 AppVersion={#ApplicationVersion}
DefaultDirName={autopf}\EMLy DefaultDirName={autopf}\EMLy
OutputBaseFilename=EMLy_Installer_1.2.2 OutputBaseFilename={#ApplicationName}_Installer_{#ApplicationVersion}
ArchitecturesInstallIn64BitMode=x64compatible ArchitecturesInstallIn64BitMode=x64compatible
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
; Request administrative privileges for HKA to write to HKLM if needed, ; Request administrative privileges for HKA to write to HKLM if needed,
; or use "lowest" if purely per-user, but file associations usually work better with admin rights or proper HKA handling. ; or use "lowest" if purely per-user, but file associations usually work better with admin rights or proper HKA handling.
PrivilegesRequired=admin PrivilegesRequired=admin
SetupIconFile=..\build\windows\icon.ico SetupIconFile=..\build\windows\icon.ico
UninstallDisplayIcon={app}\EMLy.exe UninstallDisplayIcon={app}\{#ApplicationName}.exe
AppVerName={#ApplicationName} {#ApplicationVersion}
[Files] [Files]
; Source path relative to this .iss file (assuming it is in the "installer" folder and build is in "../build") ; Source path relative to this .iss file (assuming it is in the "installer" folder and build is in "../build")
Source: "..\build\bin\EMLy.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\build\bin\{#ApplicationName}.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\build\bin\config.ini"; DestDir: "{app}"; Flags: ignoreversion Source: "..\build\bin\config.ini"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\build\bin\signed_msg.exe"; DestDir: "{app}"; Flags: ignoreversion
[Registry] [Registry]
; 1. Register the .eml extension and point it to our internal ProgID "EMLy.EML" ; 1. Register the .eml extension and point it to our internal ProgID "EMLy.EML"
Root: HKA; Subkey: "Software\Classes\.eml"; ValueType: string; ValueName: ""; ValueData: "EMLy.EML"; Flags: uninsdeletevalue Root: HKA; Subkey: "Software\Classes\.eml"; ValueType: string; ValueName: ""; ValueData: "{#ApplicationName}.EML"; Flags: uninsdeletevalue
Root: HKA; Subkey: "Software\Classes\.msg"; ValueType: string; ValueName: ""; ValueData: "EMLy.MSG"; Flags: uninsdeletevalue Root: HKA; Subkey: "Software\Classes\.msg"; ValueType: string; ValueName: ""; ValueData: "{#ApplicationName}.MSG"; Flags: uninsdeletevalue
; 2. Define the ProgID with a readable name and icon ; 2. Define the ProgID with a readable name and icon
Root: HKA; Subkey: "Software\Classes\EMLy.EML"; ValueType: string; ValueName: ""; ValueData: "EMLy Email Message"; Flags: uninsdeletekey Root: HKA; Subkey: "Software\Classes\{#ApplicationName}.EML"; ValueType: string; ValueName: ""; ValueData: "{#ApplicationName} Email Message"; Flags: uninsdeletekey
Root: HKA; Subkey: "Software\Classes\EMLy.EML\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\EMLy.exe,0" Root: HKA; Subkey: "Software\Classes\{#ApplicationName}.EML\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#ApplicationName}.exe,0"
Root: HKA; Subkey: "Software\Classes\EMLy.MSG"; ValueType: string; ValueName: ""; ValueData: "EMLy Outlook Message"; Flags: uninsdeletekey Root: HKA; Subkey: "Software\Classes\{#ApplicationName}.MSG"; ValueType: string; ValueName: ""; ValueData: "{#ApplicationName} Outlook Message"; Flags: uninsdeletekey
Root: HKA; Subkey: "Software\Classes\EMLy.MSG\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\EMLy.exe,0" Root: HKA; Subkey: "Software\Classes\{#ApplicationName}.MSG\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#ApplicationName}.exe,0"
; 3. Define the open command ; 3. Define the open command
; "%1" passes the file path to the application ; "%1" passes the file path to the application
Root: HKA; Subkey: "Software\Classes\EMLy.EML\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\EMLy.exe"" ""%1""" Root: HKA; Subkey: "Software\Classes\{#ApplicationName}.EML\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\EMLy.exe"" ""%1"""
Root: HKA; Subkey: "Software\Classes\EMLy.MSG\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\EMLy.exe"" ""%1""" Root: HKA; Subkey: "Software\Classes\{#ApplicationName}.MSG\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\EMLy.exe"" ""%1"""
; Optional: Add "Open with EMLy" to context menu explicitly (though file association typically handles the double click) ; Optional: Add "Open with EMLy" to context menu explicitly (though file association typically handles the double click)
Root: HKA; Subkey: "Software\Classes\EMLy.EML\shell\open"; ValueType: string; ValueName: "FriendlyAppName"; ValueData: "EMLy" Root: HKA; Subkey: "Software\Classes\{#ApplicationName}.EML\shell\open"; ValueType: string; ValueName: "FriendlyAppName"; ValueData: "{#ApplicationName}"
Root: HKA; Subkey: "Software\Classes\EMLy.MSG\shell\open"; ValueType: string; ValueName: "FriendlyAppName"; ValueData: "EMLy" Root: HKA; Subkey: "Software\Classes\{#ApplicationName}.MSG\shell\open"; ValueType: string; ValueName: "FriendlyAppName"; ValueData: "{#ApplicationName}"
[Icons] [Icons]
Name: "{autoprograms}\EMLy"; Filename: "{app}\EMLy.exe" Name: "{autoprograms}\{#ApplicationName}"; Filename: "{app}\{#ApplicationName}.exe"