Compare commits
9 Commits
0cda0a26fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad1116db14 | ||
|
|
7cfc12c6ef | ||
|
|
e9b013412a | ||
|
|
671f5aa8aa | ||
|
|
3fb2f95e8a | ||
|
|
654475d3ea | ||
|
|
3c24421c8c | ||
|
|
1e84320588 | ||
|
|
e7d1850a63 |
24
app.go
24
app.go
@@ -44,7 +44,20 @@ func NewApp() *App {
|
|||||||
// so we can call the runtime methods
|
// so we can call the runtime methods
|
||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
|
||||||
|
isViewer := false
|
||||||
|
for _, arg := range os.Args {
|
||||||
|
if strings.Contains(arg, "--view-image") || strings.Contains(arg, "--view-pdf") {
|
||||||
|
isViewer = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isViewer {
|
||||||
|
Log("Second instance launch")
|
||||||
|
} else {
|
||||||
Log("Wails startup")
|
Log("Wails startup")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetConfig() *utils.Config {
|
func (a *App) GetConfig() *utils.Config {
|
||||||
@@ -101,12 +114,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 +510,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()
|
||||||
|
}
|
||||||
|
|||||||
11
backend/utils/debug_windows.go
Normal file
11
backend/utils/debug_windows.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
@@ -8,8 +10,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 +39,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)
|
||||||
}
|
}
|
||||||
@@ -59,10 +59,68 @@ func ReadEmlFile(filePath string) (*EmailData, error) {
|
|||||||
body = email.TextBody
|
body = email.TextBody
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process attachments and detect PEC
|
// Process attachments list and PEC detection
|
||||||
var attachments []EmailAttachment
|
var attachments []EmailAttachment
|
||||||
var hasDatiCert, hasSmime, hasInnerEmail bool
|
var hasDatiCert, hasSmime, hasInnerEmail bool
|
||||||
|
|
||||||
|
// Process embedded files (inline images) -> add to body AND add as attachments
|
||||||
|
for _, ef := range email.EmbeddedFiles {
|
||||||
|
data, err := io.ReadAll(ef.Data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to base64
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(data)
|
||||||
|
mimeType := ef.ContentType
|
||||||
|
if parts := strings.Split(mimeType, ";"); len(parts) > 0 {
|
||||||
|
mimeType = strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create data URI
|
||||||
|
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, b64)
|
||||||
|
|
||||||
|
// Replace cid:reference with data URI in HTML body
|
||||||
|
// ef.CID is already trimmed of <>
|
||||||
|
target := "cid:" + ef.CID
|
||||||
|
body = strings.ReplaceAll(body, target, dataURI)
|
||||||
|
|
||||||
|
// ALSO ADD AS ATTACHMENTS for the viewer
|
||||||
|
filename := ef.CID
|
||||||
|
if filename == "" {
|
||||||
|
filename = "embedded_image"
|
||||||
|
}
|
||||||
|
// If no extension, try to infer from mimetype
|
||||||
|
if !strings.Contains(filename, ".") {
|
||||||
|
ext := "dat"
|
||||||
|
switch mimeType {
|
||||||
|
case "image/jpeg":
|
||||||
|
ext = "jpg"
|
||||||
|
case "image/png":
|
||||||
|
ext = "png"
|
||||||
|
case "image/gif":
|
||||||
|
ext = "gif"
|
||||||
|
case "application/pdf":
|
||||||
|
ext = "pdf"
|
||||||
|
default:
|
||||||
|
if parts := strings.Split(mimeType, "/"); len(parts) > 1 {
|
||||||
|
ext = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filename = fmt.Sprintf("%s.%s", filename, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachments = append(attachments, EmailAttachment{
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: mimeType,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process standard attachments
|
||||||
for _, att := range email.Attachments {
|
for _, att := range email.Attachments {
|
||||||
data, err := io.ReadAll(att.Data)
|
data, err := io.ReadAll(att.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,3 +180,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfb.header.Signature != cfbSignature {
|
||||||
|
return nil, errors.New("invalid MSG file")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfb.sectorSize = 1 << cfb.header.SectorShift
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfb *cfbReader) sectorOffset(sector uint32) int64 {
|
||||||
|
return int64(sector+1) * int64(cfb.sectorSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfb *cfbReader) readSector(sector uint32) ([]byte, error) {
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read json output: %w", err)
|
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:])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Parse JSON
|
entriesPerSector := cfb.sectorSize / 4
|
||||||
var pecJson struct {
|
cfb.fat = make([]uint32, 0, len(fatSectors)*entriesPerSector)
|
||||||
Subject string `json:"subject"`
|
for _, sector := range fatSectors {
|
||||||
From string `json:"from"`
|
data, err := cfb.readSector(sector)
|
||||||
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 {
|
|
||||||
return nil, fmt.Errorf("failed to parse json output: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Check for postacert.eml to determine if it is a PEC
|
|
||||||
var foundPostacert bool
|
|
||||||
var hasDatiCert, hasSmime bool
|
|
||||||
|
|
||||||
// We'll prepare attachments listing at the same time
|
|
||||||
var attachments []EmailAttachment
|
|
||||||
|
|
||||||
for _, att := range pecJson.Attachments {
|
|
||||||
attData, err := base64.StdEncoding.DecodeString(att.Data)
|
|
||||||
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, "&", "&")
|
||||||
|
text = strings.ReplaceAll(text, "<", "<")
|
||||||
|
text = strings.ReplaceAll(text, ">", ">")
|
||||||
|
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
|
||||||
}
|
}
|
||||||
if filenameLower == "daticert.xml" {
|
case pidTagAttachFilename:
|
||||||
hasDatiCert = true
|
if att.Filename == "" {
|
||||||
|
att.Filename = decodePropertyString(data, propType)
|
||||||
|
}
|
||||||
|
case pidTagAttachMimeTag:
|
||||||
|
att.ContentType = decodePropertyString(data, propType)
|
||||||
|
case pidTagAttachData:
|
||||||
|
att.Data = data
|
||||||
}
|
}
|
||||||
if filenameLower == "smime.p7s" {
|
|
||||||
hasSmime = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if att.Filename == "" && att.Data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return att
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodePropertyString(data []byte, propType uint16) string {
|
||||||
|
switch propType {
|
||||||
|
case propTypeString:
|
||||||
|
return decodeUTF16(data)
|
||||||
|
case propTypeString8:
|
||||||
|
return strings.TrimRight(string(data), "\x00")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractMIMEAttachments(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 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{
|
attachments = append(attachments, EmailAttachment{
|
||||||
Filename: att.Filename,
|
Filename: filename,
|
||||||
ContentType: att.ContentType,
|
ContentType: "message/rfc822",
|
||||||
Data: attData,
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if !foundPostacert {
|
|
||||||
// Maybe its a normal MSG, continue to try to parse it as a regular email
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. It is a PEC. Return the outer message (wrapper)
|
return attachments
|
||||||
// so the user can see the PEC envelope and attachments (postacert.eml, etc.)
|
}
|
||||||
|
|
||||||
body := pecJson.HtmlBody
|
func extractFromRFC822(data []byte) []EmailAttachment {
|
||||||
if body == "" {
|
data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
|
||||||
body = pecJson.Body
|
data = bytes.ReplaceAll(data, []byte("\n"), []byte("\r\n"))
|
||||||
}
|
|
||||||
|
reader := bufio.NewReader(bytes.NewReader(data))
|
||||||
return &EmailData{
|
tp := textproto.NewReader(reader)
|
||||||
From: convertToUTF8(pecJson.From),
|
headers, err := tp.ReadMIMEHeader()
|
||||||
To: pecJson.To, // Assuming format is already correct or compatible
|
if err != nil {
|
||||||
Cc: pecJson.Cc,
|
return nil
|
||||||
Bcc: pecJson.Bcc,
|
}
|
||||||
Subject: convertToUTF8(pecJson.Subject),
|
|
||||||
Body: convertToUTF8(body),
|
contentType := headers.Get("Content-Type")
|
||||||
Attachments: attachments,
|
mediaType, params, _ := mime.ParseMediaType(contentType)
|
||||||
IsPec: hasDatiCert || hasSmime, // Typical PEC indicators
|
|
||||||
HasInnerEmail: foundPostacert,
|
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||||
}, nil
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[EMLy]
|
[EMLy]
|
||||||
SDK_DECODER_SEMVER="1.2.1-hotfix_1"
|
SDK_DECODER_SEMVER="1.3.1"
|
||||||
SDK_DECODER_RELEASE_CHANNEL="alpha"
|
SDK_DECODER_RELEASE_CHANNEL="beta"
|
||||||
GUI_SEMVER="1.2.2"
|
GUI_SEMVER="1.3.0"
|
||||||
GUI_RELEASE_CHANNEL="beta"
|
GUI_RELEASE_CHANNEL="beta"
|
||||||
LANGUAGE="it"
|
LANGUAGE="it"
|
||||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -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
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
80dcc9e81c52df44518195ddfd550d26
|
3c4a64d0cfb34e86fac16fceae842e43
|
||||||
@@ -1,30 +1,35 @@
|
|||||||
<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";
|
||||||
|
import { dev } from "$app/environment";
|
||||||
|
|
||||||
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>";
|
||||||
|
|
||||||
function onClear() {
|
function onClear() {
|
||||||
mailState.clear();
|
mailState.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
console.log("Current email changed:", mailState.currentEmail);
|
if(dev) {
|
||||||
|
console.log(mailState.currentEmail)
|
||||||
|
}
|
||||||
|
console.info("Current email changed:", mailState.currentEmail?.subject);
|
||||||
if(mailState.currentEmail !== null) {
|
if(mailState.currentEmail !== null) {
|
||||||
sidebarOpen.set(false);
|
sidebarOpen.set(false);
|
||||||
}
|
}
|
||||||
console.log(mailState.currentEmail?.attachments)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -47,21 +52,12 @@
|
|||||||
let emlContent;
|
let emlContent;
|
||||||
|
|
||||||
if (lowerArg.endsWith(".msg")) {
|
if (lowerArg.endsWith(".msg")) {
|
||||||
const useExt = settingsStore.settings.useMsgConverter ?? true;
|
|
||||||
if (useExt) {
|
|
||||||
loadingText = m.mail_loading_msg_conversion();
|
loadingText = m.mail_loading_msg_conversion();
|
||||||
}
|
emlContent = await ReadMSG(arg, true);
|
||||||
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,7 +99,11 @@
|
|||||||
} 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 +141,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;
|
|
||||||
if (useExt) {
|
|
||||||
loadingText = m.mail_loading_msg_conversion();
|
loadingText = m.mail_loading_msg_conversion();
|
||||||
}
|
email = await ReadMSG(result, true);
|
||||||
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 +176,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 +279,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 +305,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 +331,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 +630,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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
2
frontend/src/lib/types.d.ts
vendored
2
frontend/src/lib/types.d.ts
vendored
@@ -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";
|
||||||
|
|||||||
56
frontend/src/lib/utils/logger-hook.ts
Normal file
56
frontend/src/lib/utils/logger-hook.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { FrontendLog } from '$lib/wailsjs/go/main/App';
|
||||||
|
|
||||||
|
function safeStringify(obj: any): string {
|
||||||
|
try {
|
||||||
|
if (typeof obj === 'object' && obj !== null) {
|
||||||
|
return JSON.stringify(obj);
|
||||||
|
}
|
||||||
|
return String(obj);
|
||||||
|
} catch (e) {
|
||||||
|
return '[Circular/Error]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupConsoleLogger() {
|
||||||
|
if ((window as any).__logger_initialized__) return;
|
||||||
|
(window as any).__logger_initialized__ = true;
|
||||||
|
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const originalError = console.error;
|
||||||
|
const originalInfo = console.info;
|
||||||
|
|
||||||
|
function logToBackend(level: string, args: any[]) {
|
||||||
|
try {
|
||||||
|
// Avoid logging if wails runtime is not ready or function is missing
|
||||||
|
if (typeof FrontendLog !== 'function') return;
|
||||||
|
|
||||||
|
const message = args.map(arg => safeStringify(arg)).join(' ');
|
||||||
|
FrontendLog(level, message).catch(() => {});
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log = (...args) => {
|
||||||
|
originalLog(...args);
|
||||||
|
logToBackend("INFO", args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...args) => {
|
||||||
|
originalWarn(...args);
|
||||||
|
logToBackend("WARN", args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args) => {
|
||||||
|
originalError(...args);
|
||||||
|
logToBackend("ERROR", args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.info = (...args) => {
|
||||||
|
originalInfo(...args);
|
||||||
|
logToBackend("INFO", args);
|
||||||
|
};
|
||||||
|
|
||||||
|
originalLog("Console logger hooked to Wails backend");
|
||||||
|
}
|
||||||
4
frontend/src/lib/wailsjs/go/main/App.d.ts
vendored
4
frontend/src/lib/wailsjs/go/main/App.d.ts
vendored
@@ -6,6 +6,8 @@ import {internal} from '../models';
|
|||||||
|
|
||||||
export function CheckIsDefaultEMLHandler():Promise<boolean>;
|
export function CheckIsDefaultEMLHandler():Promise<boolean>;
|
||||||
|
|
||||||
|
export function FrontendLog(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function GetConfig():Promise<utils.Config>;
|
export function GetConfig():Promise<utils.Config>;
|
||||||
|
|
||||||
export function GetImageViewerData():Promise<main.ImageViewerData>;
|
export function GetImageViewerData():Promise<main.ImageViewerData>;
|
||||||
@@ -18,6 +20,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>;
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export function CheckIsDefaultEMLHandler() {
|
|||||||
return window['go']['main']['App']['CheckIsDefaultEMLHandler']();
|
return window['go']['main']['App']['CheckIsDefaultEMLHandler']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FrontendLog(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['FrontendLog'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetConfig() {
|
export function GetConfig() {
|
||||||
return window['go']['main']['App']['GetConfig']();
|
return window['go']['main']['App']['GetConfig']();
|
||||||
}
|
}
|
||||||
@@ -30,6 +34,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']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 @@
|
|||||||
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";
|
||||||
|
import { settingsStore } from "$lib/stores/settings.svelte.js";
|
||||||
|
|
||||||
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);
|
||||||
|
let isDebbugerProtectionOn: boolean = $state(true);
|
||||||
|
|
||||||
async function syncMaxState() {
|
async function syncMaxState() {
|
||||||
isMaximized = await WindowIsMaximised();
|
isMaximized = await WindowIsMaximised();
|
||||||
@@ -67,9 +71,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
if (browser && isDebbugerProtectionOn) {
|
||||||
|
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";
|
||||||
@@ -94,6 +120,8 @@
|
|||||||
} catch {
|
} catch {
|
||||||
stored = null;
|
stored = null;
|
||||||
}
|
}
|
||||||
|
isDebbugerProtectionOn = settingsStore.settings.enableAttachedDebuggerProtection ? true : false;
|
||||||
|
$inspect(isDebbugerProtectionOn, "isDebbugerProtectionOn");
|
||||||
|
|
||||||
applyTheme(stored === "light" ? "light" : "dark");
|
applyTheme(stored === "light" ? "light" : "dark");
|
||||||
});
|
});
|
||||||
@@ -101,7 +129,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 +141,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 +152,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 +326,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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})`
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { setupConsoleLogger } from '$lib/utils/logger-hook';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
setupConsoleLogger();
|
||||||
const loader = document.getElementById('app-loading');
|
const loader = document.getElementById('app-loading');
|
||||||
if (loader) {
|
if (loader) {
|
||||||
loader.style.opacity = '0';
|
loader.style.opacity = '0';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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) {
|
||||||
@@ -81,7 +81,8 @@
|
|||||||
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 =
|
||||||
|
"Timeout loading PDF. The worker might have failed to initialize.";
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
@@ -117,7 +118,7 @@
|
|||||||
const viewport = page.getViewport({ scale: scale, rotation: rotation });
|
const viewport = page.getViewport({ scale: scale, rotation: rotation });
|
||||||
|
|
||||||
const canvas = canvasRef;
|
const canvas = canvasRef;
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext("2d");
|
||||||
|
|
||||||
if (!context) return;
|
if (!context) return;
|
||||||
|
|
||||||
@@ -126,13 +127,13 @@
|
|||||||
|
|
||||||
const renderContext = {
|
const renderContext = {
|
||||||
canvasContext: context,
|
canvasContext: context,
|
||||||
viewport: viewport
|
viewport: viewport,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cast to any to avoid type mismatch with PDF.js definitions
|
// Cast to any to avoid type mismatch with PDF.js definitions
|
||||||
await page.render(renderContext as any).promise;
|
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;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +183,6 @@
|
|||||||
pageNum--;
|
pageNum--;
|
||||||
renderPage(pageNum);
|
renderPage(pageNum);
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="viewer-container">
|
<div class="viewer-container">
|
||||||
@@ -352,8 +354,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 10px;
|
||||||
height: 6px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
54
logger.go
54
logger.go
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -10,7 +11,47 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var logger = log.New(os.Stdout, "", 0)
|
var (
|
||||||
|
logger = log.New(os.Stdout, "", 0)
|
||||||
|
logFile *os.File
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitLogger initializes the logger to write to both stdout and a file in AppData
|
||||||
|
func InitLogger() error {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
appDir := filepath.Join(configDir, "EMLy")
|
||||||
|
logsDir := filepath.Join(appDir, "logs")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath := filepath.Join(logsDir, "app.log")
|
||||||
|
// Open file in Append mode
|
||||||
|
file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logFile = file
|
||||||
|
|
||||||
|
// MultiWriter to write to both stdout and file
|
||||||
|
multi := io.MultiWriter(os.Stdout, file)
|
||||||
|
logger = log.New(multi, "", 0)
|
||||||
|
|
||||||
|
Log("Logger initialized. Writing to: " + logPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseLogger closes the log file
|
||||||
|
func CloseLogger() {
|
||||||
|
if logFile != nil {
|
||||||
|
logFile.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Log prints a timestamped, file:line tagged log line.
|
// Log prints a timestamped, file:line tagged log line.
|
||||||
func Log(args ...any) {
|
func Log(args ...any) {
|
||||||
@@ -27,3 +68,14 @@ func Log(args ...any) {
|
|||||||
msg := fmt.Sprintln(args...)
|
msg := fmt.Sprintln(args...)
|
||||||
logger.Printf("[%s] - [%s] - [%s] - %s", date, tm, loc, strings.TrimRight(msg, "\n"))
|
logger.Printf("[%s] - [%s] - [%s] - %s", date, tm, loc, strings.TrimRight(msg, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FrontendLog allows the frontend to send logs to the backend logger
|
||||||
|
func (a *App) FrontendLog(level string, message string) {
|
||||||
|
now := time.Now()
|
||||||
|
date := now.Format("2006-01-02")
|
||||||
|
tm := now.Format("15:04:05")
|
||||||
|
|
||||||
|
// We don't use runtime.Caller here because it would point to this function
|
||||||
|
// Instead we tag it as [FRONTEND]
|
||||||
|
logger.Printf("[%s] - [%s] - [FRONTEND] - [%s] %s", date, tm, level, message)
|
||||||
|
}
|
||||||
|
|||||||
5
main.go
5
main.go
@@ -28,6 +28,11 @@ func (a *App) onSecondInstanceLaunch(secondInstanceData options.SecondInstanceDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := InitLogger(); err != nil {
|
||||||
|
log.Println("Error initializing logger:", err)
|
||||||
|
}
|
||||||
|
defer CloseLogger()
|
||||||
|
|
||||||
// Check for custom args
|
// Check for custom args
|
||||||
args := os.Args
|
args := os.Args
|
||||||
uniqueId := "emly-app-lock"
|
uniqueId := "emly-app-lock"
|
||||||
|
|||||||
Reference in New Issue
Block a user