diff options
| -rw-r--r-- | jobs/emails.go | 1 | ||||
| -rw-r--r-- | models/email.go | 56 | ||||
| -rw-r--r-- | services/emails.go | 48 | ||||
| -rw-r--r-- | static/css/main.css | 685 | ||||
| -rw-r--r-- | static/js/dropdown.js | 18 | ||||
| -rw-r--r-- | static/js/icons.js | 24 | ||||
| -rw-r--r-- | static/js/mail.js | 390 | ||||
| -rw-r--r-- | static/js/shadow.js | 50 | ||||
| -rw-r--r-- | static/js/utils.js | 113 | ||||
| -rw-r--r-- | templates/mail/folder.django | 3 | ||||
| -rw-r--r-- | types/email.go | 1 | ||||
| -rw-r--r-- | utils/email/messages.go | 63 | ||||
| -rw-r--r-- | utils/format/html.go | 49 |
13 files changed, 1220 insertions, 281 deletions
diff --git a/jobs/emails.go b/jobs/emails.go index 350b454..f5abda8 100644 --- a/jobs/emails.go +++ b/jobs/emails.go @@ -60,6 +60,7 @@ func SyncEmails(userEmail string, folderID uint, folderPath string) error { Date: msg.Date, BodyText: msg.BodyText, BodyHTML: msg.BodyHTML, + RawHeaders: msg.RawHeaders, Snippet: snippet, Size: int64(msg.Size), InReplyTo: msg.InReplyTo, diff --git a/models/email.go b/models/email.go index 84474b7..5c884ea 100644 --- a/models/email.go +++ b/models/email.go @@ -7,37 +7,37 @@ import ( ) type Email struct { - gorm.Model - UserEmail string - FolderID uint - Folder Folder `gorm:"foreignKey:FolderID"` - - UID uint32 `gorm:"index"` - MessageID string `gorm:"index"` - - From string - FromName string - To string - CC string - BCC string - ReplyTo string - - Subject string - Date time.Time `gorm:"index"` - - BodyText string `gorm:"type:text"` - BodyHTML string `gorm:"type:text"` - Snippet string - - IsRead bool `gorm:"default:false;index"` - IsFlagged bool `gorm:"default:false;index"` + ID uint `gorm:"primaryKey"` + UserEmail string `gorm:"index:idx_user_folder,priority:1;not null"` + FolderID uint `gorm:"index:idx_user_folder,priority:2;not null"` + UID uint32 `gorm:"not null"` + MessageID string `gorm:"index"` + From string `gorm:"not null"` + FromName string + To string `gorm:"not null"` + CC string + BCC string + ReplyTo string + Subject string + Date time.Time `gorm:"index"` + BodyText string `gorm:"type:text"` + BodyHTML string `gorm:"type:text"` + RawHeaders string `gorm:"type:text"` + Snippet string + Size int64 + InReplyTo string + IsRead bool `gorm:"default:false"` + IsFlagged bool `gorm:"default:false"` IsAnswered bool `gorm:"default:false"` IsDraft bool `gorm:"default:false"` - HasAttachment bool `gorm:"default:false;index"` + HasAttachment bool `gorm:"default:false"` + + Folder Folder `gorm:"foreignKey:FolderID"` + Attachments []Attachment `gorm:"foreignKey:EmailID"` - Size int64 - InReplyTo string - References string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` } type Attachment struct { diff --git a/services/emails.go b/services/emails.go index 4b440f4..fa1d4b2 100644 --- a/services/emails.go +++ b/services/emails.go @@ -65,7 +65,11 @@ func GetEmailDetails(userEmail string, emailID uint) (fiber.Map, error) { return nil, err } - // Get attachments + prefs, err := repository.GetPreferencesByEmail(userEmail) + if err != nil { + return nil, err + } + attachments, _ := repository.GetAttachmentsByEmailID(emailID) var attachmentMaps []fiber.Map @@ -78,7 +82,6 @@ func GetEmailDetails(userEmail string, emailID uint) (fiber.Map, error) { }) } - // Sanitize HTML body body := message.BodyHTML if body == "" { body = "<pre>" + format.DecodeHTML(message.BodyText) + "</pre>" @@ -86,18 +89,37 @@ func GetEmailDetails(userEmail string, emailID uint) (fiber.Map, error) { body = format.SanitizeHTML(body) } + bodyText := message.BodyText + if bodyText == "" && message.BodyHTML != "" { + bodyText = format.HTMLToPlainText(message.BodyHTML) + } + + fromName := message.FromName + fromEmail := message.From + fromFormatted := fromEmail + if fromName != "" && fromName != fromEmail { + fromFormatted = fromName + " <" + fromEmail + ">" + } + + toFormatted := message.To + return fiber.Map{ - "ID": message.ID, - "Subject": format.DecodeHTML(message.Subject), - "From": format.DecodeHTML(message.From), - "FromName": format.DecodeHTML(message.FromName), - "To": format.DecodeHTML(message.To), - "CC": format.DecodeHTML(message.CC), - "Date": message.Date, - "Body": body, - "IsRead": message.IsRead, - "IsFlagged": message.IsFlagged, - "Attachments": attachmentMaps, + "ID": message.ID, + "MessageID": message.MessageID, + "Subject": format.DecodeHTML(message.Subject), + "From": format.DecodeHTML(fromFormatted), + "FromName": format.DecodeHTML(fromName), + "FromEmail": format.DecodeHTML(fromEmail), + "To": format.DecodeHTML(toFormatted), + "CC": format.DecodeHTML(message.CC), + "Date": message.Date, + "DateFormatted": format.FormatEmailDate(message.Date, prefs.DateFormat, prefs.TimeFormat, prefs.PrettyDates, prefs.TimeZone), + "Body": body, + "BodyText": format.DecodeHTML(bodyText), + "RawHeaders": message.RawHeaders, + "IsRead": message.IsRead, + "IsFlagged": message.IsFlagged, + "Attachments": attachmentMaps, }, nil } diff --git a/static/css/main.css b/static/css/main.css index de658ba..4e26802 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -716,41 +716,303 @@ input[type="date"]:focus { .preview { background: var(--bg-primary); overflow-y: auto; - padding: 12px; + padding: 16px; } .email-header { + background: var(--bg-secondary); + border: 2px solid var(--accent-primary); + margin-bottom: 0; +} + +.email-header-main { display: flex; - justify-content: space-between; - margin-bottom: 12px; - padding-bottom: 8px; - border-bottom: 1px solid var(--border-color); + padding: 16px; + gap: 16px; + border-bottom: 1px solid var(--border-accent); } -.email-actions { +.email-profile-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.profile-initials { + font-size: 24px; + font-weight: bold; + color: var(--accent-primary); + font-family: 'MS PGothic', monospace; +} + +.profile-domain { + font-size: 9px; + color: var(--accent-tertiary); + font-family: 'MS PGothic', monospace; + text-transform: uppercase; +} + +.email-info-section { + flex: 1; + min-width: 0; +} + +.email-meta-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px 12px; +} + +.email-meta-label { + color: var(--accent-tertiary); + font-weight: bold; + min-width: 40px; +} + +.email-iframe { + width: 100%; + border: none; + display: block; + background: #ffffff; + min-height: 400px; +} + +.email-meta-item { display: flex; + gap: 6px; + font-size: 10px; + font-family: 'MS PGothic', monospace; + align-items: flex-start; +} + +.email-meta-full .email-meta-value { + white-space: normal; + word-wrap: break-word; +} + +.email-meta-value { + color: var(--text-primary); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; gap: 4px; } -.btn-icon { +.email-meta-value span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.add-contact-btn { + background: none; + border: 1px solid var(--border-color); + color: var(--text-muted); + cursor: pointer; + padding: 2px 4px; + font-size: 10px; + flex-shrink: 0; +} + +.add-contact-btn:hover { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.action-section { + display: flex; + border-right: 1px solid var(--border-accent); +} + +.action-section:last-child { + border-right: none; +} + + + +.email-body-container { + background: var(--bg-secondary); + border: 2px solid var(--accent-primary); + border-top: none; + padding: 0; +} + + + +.attachment { + display: inline-block; + margin: 3px 4px 3px 0; + padding: 4px 8px; background: var(--bg-secondary); + color: var(--accent-primary); + font-size: 10px; border: 1px solid var(--border-color); +} + +.attachment:hover { + border-color: var(--accent-primary); +} + +.email-summary { + font-size: 11px; color: var(--text-secondary); - font-size: 12px; - padding: 5px; + margin-top: 8px; +} + +.sender-name { + color: var(--accent-primary); cursor: pointer; - width: 26px; - height: 26px; + text-decoration: underline; +} + +.sender-name:hover { + color: var(--accent-secondary); +} + +.email-details-container { + margin-top: 12px; +} + +.email-detail-label { + font-weight: bold; + color: var(--text-secondary); + min-width: 60px; +} + +.email-profile { + width: 64px; + height: 64px; + border: 2px solid var(--accent-primary); + background: #ffffff; display: flex; align-items: center; justify-content: center; + padding: 4px; } -.btn-icon:hover { - border-color: var(--accent-primary); +.profile-img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.email-meta-full { + grid-column: 1 / -1; +} + +.email-body { + padding: 12px; + overflow-x: auto; +} + +.email-body pre { + margin: 0; + font-family: 'MS PGothic', monospace; + font-size: 11px; + line-height: 1.4; + color: var(--text-primary); + white-space: pre; + overflow-x: auto; +} + +.email-body pre.word-wrap-enabled { + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + overflow-x: hidden; +} + +.email-body pre a { color: var(--accent-primary); + text-decoration: underline; +} + +.email-body pre a:hover { + color: var(--accent-secondary); +} + +.no-email-selected { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + font-size: 13px; +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.modal-dialog { + background: var(--bg-secondary); + border: 2px solid var(--accent-primary); + padding: 20px; + max-width: 700px; + width: 90%; + max-height: 80vh; + overflow: auto; +} + +.modal-title { + color: var(--accent-primary); + margin-bottom: 12px; + font-size: 13px; + font-weight: bold; +} + +.modal-content { + background: var(--bg-primary); + padding: 12px; + border: 1px solid var(--border-color); + color: var(--text-primary); + font-size: 10px; + font-family: 'MS PGothic', monospace; + margin-bottom: 12px; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 500px; + overflow: auto; +} + +.btn-close { + padding: 8px 16px; + background: var(--accent-primary); + color: var(--bg-primary); + border: 1px solid var(--accent-primary); + cursor: pointer; + font-size: 11px; + font-family: inherit; +} + +.btn-close:hover { + background: var(--accent-secondary); + border-color: var(--accent-secondary); } +.email-actions { + display: flex; + gap: 4px; +} + + + .email-sender { display: flex; justify-content: space-between; @@ -774,43 +1036,404 @@ input[type="date"]:focus { color: var(--text-secondary); } -.email-attachments { - margin-bottom: 8px; - padding: 8px; - background: var(--bg-tertiary); +.email-top-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.email-subject-display { + font-size: 16px; + font-weight: bold; + color: var(--accent-secondary); + flex: 1; +} + +.email-actions-bar { + display: flex; + gap: 1px; + flex-shrink: 0; +} + +.action-btn { + background: var(--bg-secondary); border: 1px solid var(--border-color); + color: var(--text-primary); + cursor: pointer; + padding: 2px 4px; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; +} + +.action-btn:hover { + background: var(--accent-primary); + border-color: var(--accent-primary); +} + +.action-btn.active { + background: var(--accent-secondary); + border-color: var(--accent-secondary); +} + +.action-btn svg { + width: 14px; + height: 14px; +} + +.email-summary { font-size: 11px; + color: var(--text-secondary); + margin-top: 8px; } -.attachment { - display: inline-block; - margin: 3px 4px; - padding: 4px 8px; - background: var(--bg-secondary); +.sender-name { color: var(--accent-primary); + cursor: pointer; + text-decoration: underline; +} + +.sender-name:hover { + color: var(--accent-secondary); +} + +.email-detail-item { + display: flex; + gap: 8px; font-size: 10px; + font-family: 'MS PGothic', monospace; + margin-bottom: 4px; +} + +.email-detail-value { + color: var(--text-primary); + word-wrap: break-word; +} + +.sender-menu { + position: fixed; + background: var(--bg-primary); border: 1px solid var(--border-color); + box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.3); + z-index: 1000; + min-width: 180px; } -.attachment:hover { - border-color: var(--accent-primary); +.sender-menu-item { + padding: 6px 10px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-family: 'MS PGothic', monospace; } -.email-body { - line-height: 1.6; - padding: 10px; +.sender-menu-item:hover { + background: var(--accent-primary); + color: var(--bg-primary); +} + +.sender-menu-item svg { + flex-shrink: 0; +} + +/* Email Header Redesign */ +.email-header { + border-bottom: 1px solid var(--border-color); + padding: 12px; + background: var(--bg-primary); +} + +.email-header-main { + display: flex; + gap: 16px; +} + +.email-profile-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.email-profile { + width: 64px; + height: 64px; + border: 2px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; background: var(--bg-secondary); - border: 1px solid var(--border-color); +} + +.profile-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-initials { + font-size: 24px; + font-weight: bold; + font-family: 'MS PGothic', monospace; + color: var(--text-primary); +} + +.profile-domain { + font-size: 9px; + color: var(--text-secondary); + font-family: 'MS PGothic', monospace; + text-align: center; + width: 68px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.email-content-section { + flex: 1; + min-width: 0; +} + + + +.email-subject-display { + font-size: 14px; + font-weight: bold; + color: var(--accent-secondary); + font-family: 'MS PGothic', monospace; + margin-bottom: 6px; + word-wrap: break-word; +} + +.email-summary { + font-size: 10px; + color: var(--text-secondary); + font-family: 'MS PGothic', monospace; +} + +.sender-name { + color: var(--accent-primary); + cursor: pointer; + text-decoration: underline; +} + +.sender-name:hover { + color: var(--accent-secondary); +} + +.email-details-container { + margin-top: 8px; +} + +.email-detail-item { + display: flex; + gap: 6px; + font-size: 10px; + font-family: 'MS PGothic', monospace; + margin-bottom: 3px; +} + +.email-detail-label { + font-weight: bold; + color: var(--text-secondary); + min-width: 50px; +} + +.email-detail-value { + color: var(--text-primary); + word-wrap: break-word; +} + +.sender-menu { + position: fixed; + background: var(--bg-primary); + border: 2px solid var(--border-color); + z-index: 1000; + min-width: 160px; +} + +.sender-menu-item { + padding: 6px 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + font-family: 'MS PGothic', monospace; + border-bottom: 1px solid var(--border-color); +} + +.sender-menu-item:last-child { + border-bottom: none; +} + +.sender-menu-item:hover { + background: var(--accent-primary); + color: var(--bg-primary); +} + +.sender-menu-item svg { + flex-shrink: 0; +} + +.email-card-header { + background: var(--bg-primary); +} + +.email-card { + background: var(--bg-secondary); + border: 2px solid var(--accent-primary); + padding: 0; +} + +.card-field { + display: grid; + grid-template-columns: 80px 1fr; + border-bottom: 2px solid var(--border-color); + padding: 10px 12px; + align-items: center; +} + +.card-field-large { + background: var(--bg-tertiary); +} + +.card-field:last-of-type { + border-bottom: none; +} + +.field-label { + font-size: 9px; + font-weight: bold; + color: var(--accent-tertiary); + font-family: 'MS PGothic', monospace; + letter-spacing: 1px; +} + +.field-value { font-size: 12px; + color: var(--text-primary); + font-family: 'MS PGothic', monospace; } -.no-email-selected { +.card-field-with-pic { + grid-template-columns: 80px 1fr; +} + +.field-value-with-pic { + display: flex; + gap: 12px; + align-items: center; +} + +.card-pic { + width: 48px; + height: 48px; + border: 2px solid var(--border-color); + object-fit: cover; +} + +.card-pic-init { + width: 48px; + height: 48px; + border: 2px solid var(--border-color); + background: var(--bg-tertiary); display: flex; align-items: center; justify-content: center; - height: 100%; - color: var(--text-muted); + font-size: 20px; + font-weight: bold; +} + +.sender-details { + flex: 1; +} + +.sender-name-card { font-size: 13px; + font-weight: bold; + color: var(--accent-primary); + cursor: pointer; + text-decoration: underline; + margin-bottom: 2px; +} + +.sender-name-card:hover { + color: var(--accent-secondary); +} + +.sender-email-card { + font-size: 10px; + color: var(--text-secondary); +} + + + +.email-card-header { + position: relative; + padding-bottom: 20px; +} + +.header-top-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + gap: 8px; + border-bottom: 2px solid var(--border-color); +} + +.subject-container { + flex: 1; + display: grid; + grid-template-columns: 80px 1fr; + align-items: center; +} + +.card-actions-grid { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + /* Removed absolute positioning */ +} + +.card-action-btn { + background: transparent; + border: none; + padding: 6px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + border-radius: 4px; +} + +.card-action-btn:hover { + background: var(--bg-tertiary); +} + +.card-action-btn.active { + background: var(--accent-primary); +} + +.card-action-btn svg { + width: 20px; + height: 20px; +} + + + +.subject-text { + font-weight: bold; + color: var(--accent-secondary); } .login-page { diff --git a/static/js/dropdown.js b/static/js/dropdown.js index 00b7ee6..80efd19 100644 --- a/static/js/dropdown.js +++ b/static/js/dropdown.js @@ -1,11 +1,12 @@ document.addEventListener('DOMContentLoaded', function () { - // Handle dropdown clicks - document.querySelectorAll('.options-subitem > a').forEach(function (item) { - item.addEventListener('click', function (e) { + document.addEventListener('click', function (e) { + const toggleLink = e.target.closest('a'); + + if (toggleLink && toggleLink.parentElement && toggleLink.parentElement.classList.contains('options-subitem')) { e.preventDefault(); - const parent = this.parentElement; + const parent = toggleLink.parentElement; - if (parent.classList.contains('disabled')) { + if (parent.classList.contains('disabled') || parent.getAttribute('disabled') !== null) { return; } @@ -16,11 +17,10 @@ document.addEventListener('DOMContentLoaded', function () { }); parent.classList.toggle('open'); - }); - }); + return; + } - // Close dropdowns when clicking outside - document.addEventListener('click', function (e) { + // Handle clicking outside if (!e.target.closest('.options-subitem')) { document.querySelectorAll('.options-subitem.open').forEach(function (item) { item.classList.remove('open'); diff --git a/static/js/icons.js b/static/js/icons.js new file mode 100644 index 0000000..d445ed0 --- /dev/null +++ b/static/js/icons.js @@ -0,0 +1,24 @@ +const Icons = { + Html: `<svg width="32" height="32" viewBox="0 0 32 32"><rect x="2" y="2" width="28" height="28" fill="#00CED1" stroke="#000" stroke-width="3"/><rect x="8" y="10" width="4" height="4" fill="#000"/><rect x="12" y="14" width="4" height="4" fill="#000"/><rect x="8" y="18" width="4" height="4" fill="#000"/><rect x="18" y="18" width="6" height="4" fill="#000"/></svg>`, + + Plain: `<svg width="32" height="32" viewBox="0 0 32 32"><rect x="4" y="2" width="24" height="28" fill="#E8E8E8" stroke="#000" stroke-width="3"/><rect x="8" y="8" width="16" height="3" fill="#666"/><rect x="8" y="14" width="16" height="3" fill="#666"/><rect x="8" y="20" width="12" height="3" fill="#666"/></svg>`, + + Reply: `<svg width="32" height="32" viewBox="0 0 32 32"><path d="M4 16L4 16M16 8L16 24L16 24M4 16L16 8M4 16L16 24" fill="none"/><rect x="4" y="14" width="20" height="4" fill="#FFB347" stroke="#000" stroke-width="3"/><rect x="4" y="10" width="4" height="4" fill="#FFB347" stroke="#000" stroke-width="3"/><rect x="4" y="18" width="4" height="4" fill="#FFB347" stroke="#000" stroke-width="3"/><rect x="8" y="6" width="4" height="4" fill="#FFB347" stroke="#000" stroke-width="3"/><rect x="8" y="22" width="4" height="4" fill="#FFB347" stroke="#000" stroke-width="3"/></svg>`, + + Forward: `<svg width="32" height="32" viewBox="0 0 32 32"><rect x="8" y="14" width="20" height="4" fill="#87CEEB" stroke="#000" stroke-width="3"/><rect x="24" y="10" width="4" height="4" fill="#87CEEB" stroke="#000" stroke-width="3"/><rect x="24" y="18" width="4" height="4" fill="#87CEEB" stroke="#000" stroke-width="3"/><rect x="20" y="6" width="4" height="4" fill="#87CEEB" stroke="#000" stroke-width="3"/><rect x="20" y="22" width="4" height="4" fill="#87CEEB" stroke="#000" stroke-width="3"/></svg>`, + + Details: `<svg width="32" height="32" viewBox="0 0 32 32"><rect x="14" y="4" width="4" height="24" fill="#90EE90" stroke="#000" stroke-width="3"/><rect x="4" y="14" width="24" height="4" fill="#90EE90" stroke="#000" stroke-width="3"/></svg>`, + + Wrap: `<svg width="32" height="32" viewBox="0 0 32 32"><rect x="4" y="6" width="24" height="3" fill="#DDA0DD" stroke="#000" stroke-width="2"/><rect x="4" y="12" width="18" height="3" fill="#DDA0DD" stroke="#000" stroke-width="2"/><rect x="22" y="12" width="3" height="3" fill="#DDA0DD" stroke="#000" stroke-width="2"/><rect x="22" y="15" width="3" height="3" fill="#DDA0DD" stroke="#000" stroke-width="2"/><rect x="16" y="18" width="6" height="3" fill="#DDA0DD" stroke="#000" stroke-width="2"/><rect x="12" y="18" width="4" height="3" fill="#DDA0DD" stroke="#000" stroke-width="2"/><rect x="12" y="21" width="3" height="3" fill="#DDA0DD" stroke="#000" stroke-width="2"/></svg>`, + + Headers: `<svg width="32" height="32" viewBox="0 0 32 32"><rect x="4" y="4" width="24" height="24" fill="#F0E68C" stroke="#000" stroke-width="3"/><rect x="4" y="12" width="24" height="3" fill="#000"/><rect x="4" y="20" width="24" height="3" fill="#000"/><rect x="15" y="4" width="3" height="24" fill="#000"/></svg>`, + + Summary: `<svg width="32" height="32" viewBox="0 0 32 32"><rect x="4" y="14" width="24" height="4" fill="#FFcccb" stroke="#000" stroke-width="3"/></svg>`, + + Window: `<svg width="32" height="32" viewBox="0 0 32 32"><rect x="4" y="8" width="18" height="18" fill="#B0C4DE" stroke="#000" stroke-width="3"/><rect x="10" y="4" width="18" height="4" fill="#B0C4DE" stroke="#000" stroke-width="3"/><rect x="24" y="4" width="4" height="12" fill="#B0C4DE" stroke="#000" stroke-width="3"/><rect x="20" y="4" width="4" height="4" fill="#B0C4DE"/><rect x="16" y="8" width="4" height="4" fill="#B0C4DE"/><rect x="12" y="12" width="4" height="4" fill="#B0C4DE"/></svg>`, + + addContact: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="5" r="2" stroke="currentColor" stroke-width="1.5"/><path d="M3 12c0-2 1.5-3 4-3s4 1 4 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/></svg>', + + composeMail: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M2 3h10v8H2V3zM2 3l5 4 5-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/></svg>' + +};
\ No newline at end of file diff --git a/static/js/mail.js b/static/js/mail.js index fd02120..1de18ca 100644 --- a/static/js/mail.js +++ b/static/js/mail.js @@ -5,8 +5,11 @@ document.addEventListener('DOMContentLoaded', function () { let currentEmailId = null; let markAsReadTimer = null; + let currentEmail = null; + let viewMode = 'html'; + let wordWrap = true; + let showDetails = false; - // Parse preferences from data attributes const prefs = { MarkMessagesAsRead: prefsData ? prefsData.dataset.markAsRead : 'Immediately', ShowEmailAddressWithDisplayName: prefsData ? prefsData.dataset.showAddress === 'true' : true, @@ -30,7 +33,6 @@ document.addEventListener('DOMContentLoaded', function () { currentEmailId = emailId; - // Clear previous timer if (markAsReadTimer) { clearTimeout(markAsReadTimer); } @@ -40,9 +42,15 @@ document.addEventListener('DOMContentLoaded', function () { if (!response.ok) throw new Error('Failed to fetch email'); const email = await response.json(); + currentEmail = email; + showDetails = false; renderEmail(email); - // Handle mark as read based on preference + document.querySelectorAll('.subnav .nav-subitem').forEach(item => { + item.removeAttribute('disabled'); + item.classList.remove('disabled'); + }); + if (!email.IsRead) { handleMarkAsRead(emailId, this); } @@ -53,7 +61,6 @@ document.addEventListener('DOMContentLoaded', function () { }); }); - // Handle flag clicks document.querySelectorAll('.email-flag').forEach(flag => { flag.addEventListener('click', async function (e) { e.stopPropagation(); @@ -97,9 +104,7 @@ document.addEventListener('DOMContentLoaded', function () { const delay = delays[markOption]; - if (delay === null) { - return; // Never mark as read - } + if (delay === null) return; markAsReadTimer = setTimeout(async () => { try { @@ -116,182 +121,285 @@ document.addEventListener('DOMContentLoaded', function () { }, delay); } - function renderEmail(email) { + async function renderEmail(email) { preview.innerHTML = ''; - // Header - const header = createHeader(email); + const header = await createHeader(email); preview.appendChild(header); - // Sender info - const sender = createSenderInfo(email); - preview.appendChild(sender); - - // Recipients - const recipients = createRecipients(email); - preview.appendChild(recipients); - - // Attachments - if (email.Attachments && email.Attachments.length > 0) { - const attachments = createAttachments(email.Attachments); - preview.appendChild(attachments); - } - - // Body const body = createBody(email); preview.appendChild(body); } - function createHeader(email) { + async function createHeader(email) { const header = document.createElement('div'); - header.className = 'email-header'; + header.className = 'email-card-header'; + + // Card container + const card = document.createElement('div'); + card.className = 'email-card'; + + // Header Top Row (Subject + Actions) + const headerRow = document.createElement('div'); + headerRow.className = 'header-top-row'; + + // Subject container + const subjectContainer = document.createElement('div'); + subjectContainer.className = 'subject-container'; + subjectContainer.innerHTML = ` + <div class="field-label">SUBJECT</div> + <div class="field-value subject-text">${email.Subject || '[No Subject]'}</div> + `; + + // Action buttons grid + const actionsGrid = document.createElement('div'); + actionsGrid.className = 'card-actions-grid'; + + const actions = [ + { icon: Icons.Html, label: 'Switch to HTML View', active: viewMode === 'html', onClick: () => switchViewMode('html') }, + { icon: Icons.Plain, label: 'Switch to Plain Text View', active: viewMode === 'plain', onClick: () => switchViewMode('plain') }, + { icon: Icons.Reply, label: 'Reply to Sender', onClick: () => console.log('Reply') }, + { icon: Icons.Forward, label: 'Forward Message', onClick: () => console.log('Forward') }, + { + id: 'btn-details', + icon: showDetails ? Icons.Summary : Icons.Details, + label: showDetails ? 'Hide Details' : 'Show Details', + onClick: toggleDetails + }, + { icon: Icons.Wrap, label: 'Toggle Word Wrap', active: wordWrap, onClick: () => toggleWordWrap() }, + { icon: Icons.Headers, label: 'View Message Headers', onClick: () => showHeaders(email) }, + { icon: Icons.Window, label: 'Open in New Window', onClick: () => console.log('Window') } + ]; - const subject = document.createElement('h2'); - subject.className = 'email-subject'; + actions.forEach(action => { + const btn = document.createElement('button'); + btn.className = 'card-action-btn'; + if (action.id) btn.id = action.id; + if (action.active) btn.classList.add('active'); + btn.innerHTML = action.icon; + btn.title = action.label; // Tooltip + btn.onclick = action.onClick; + actionsGrid.appendChild(btn); + }); - if (email.Subject) { - subject.textContent = email.Subject; - } else { - const noSubject = document.createElement('span'); - noSubject.className = 'no-subject'; - noSubject.textContent = '[No Subject]'; - subject.appendChild(noSubject); + headerRow.appendChild(subjectContainer); + headerRow.appendChild(actionsGrid); + + // From field with profile + const fromField = document.createElement('div'); + fromField.className = 'card-field card-field-with-pic'; + + const picUrl = await EmailUtils.getProfilePicture(email.FromEmail, email.FromName); + const escapedName = (email.FromName || '').replace(/'/g, "\\'"); + const escapedEmail = (email.FromEmail || '').replace(/'/g, "\\'"); + + fromField.innerHTML = ` + <div class="field-label">FROM</div> + <div class="field-value-with-pic"> + <img src="${picUrl}" class="card-pic" onerror="this.outerHTML='<div class=card-pic-init>${EmailUtils.getInitials(email.FromName, email.FromEmail)}</div>'"> + <div class="sender-details"> + <div class="sender-name-card" onclick="showSenderMenu(event, '${escapedName}', '${escapedEmail}')">${email.FromName || email.FromEmail}</div> + <div class="sender-email-card" onclick="showSenderMenu(event, '${escapedName}', '${escapedEmail}')">${email.FromEmail}</div> + </div> + </div> + `; + + // Date field + const dateField = document.createElement('div'); + dateField.className = 'card-field'; + dateField.innerHTML = ` + <div class="field-label">DATE</div> + <div class="field-value">${email.DateFormatted}</div> + `; + + // Details container (hidden by default) + const detailsContainer = document.createElement('div'); + detailsContainer.id = 'email-details'; + detailsContainer.style.display = 'none'; + + // TO field + const toRow = document.createElement('div'); + toRow.className = 'card-field'; + toRow.innerHTML = ` + <div class="field-label">TO</div> + <div class="field-value">${email.To}</div> + `; + detailsContainer.appendChild(toRow); + + // CC field + if (email.CC) { + const ccRow = document.createElement('div'); + ccRow.className = 'card-field'; + ccRow.innerHTML = ` + <div class="field-label">CC</div> + <div class="field-value">${email.CC}</div> + `; + detailsContainer.appendChild(ccRow); } - const actions = document.createElement('div'); - actions.className = 'email-actions'; + card.appendChild(headerRow); + card.appendChild(fromField); + card.appendChild(dateField); + card.appendChild(detailsContainer); - const actionButtons = [ - { title: 'Reply', symbol: '↶' }, - { title: 'Reply All', symbol: '⇄' }, - { title: 'Forward', symbol: '→' }, - { title: 'Archive', symbol: '▼' }, - { title: 'Delete', symbol: '×' } - ]; + // Attachments field + if (email.Attachments && email.Attachments.length > 0) { + const attRow = document.createElement('div'); + attRow.className = 'card-field'; - actionButtons.forEach(btn => { - const button = document.createElement('button'); - button.className = 'btn-icon'; - button.title = btn.title; - button.textContent = btn.symbol; - actions.appendChild(button); - }); + let attHtml = ''; + email.Attachments.forEach(att => { + attHtml += `<a href="/api/mail/attachment/${att.ID}" class="attachment" download="${att.Filename}">${att.Filename} (${att.Size})</a>`; + }); - header.appendChild(subject); - header.appendChild(actions); + attRow.innerHTML = ` + <div class="field-label">ATTACHMENTS</div> + <div class="field-value">${attHtml}</div> + `; + card.appendChild(attRow); + } + header.appendChild(card); return header; } - function createSenderInfo(email) { - const sender = document.createElement('div'); - sender.className = 'email-sender'; - const senderInfo = document.createElement('div'); - senderInfo.className = 'sender-info'; - // Respect ShowEmailAddressWithDisplayName preference - const showAddress = prefs.ShowEmailAddressWithDisplayName; + function toggleDetails() { + showDetails = !showDetails; - const strong = document.createElement('strong'); - strong.textContent = email.FromName || email.From; - senderInfo.appendChild(strong); + const detailsContainer = document.getElementById('email-details'); + const detailsBtn = document.getElementById('btn-details'); - if (showAddress && email.FromName) { - const address = document.createTextNode(` <${email.From}>`); - senderInfo.appendChild(address); + if (detailsContainer) { + detailsContainer.style.display = showDetails ? 'block' : 'none'; } - const dateDiv = document.createElement('div'); - dateDiv.className = 'email-date'; - dateDiv.textContent = formatDate(email.Date); + if (detailsBtn) { + const icon = showDetails ? Icons.Summary : Icons.Details; + const label = showDetails ? 'Hide Details' : 'Show Details'; + detailsBtn.innerHTML = icon; + detailsBtn.title = label; + } + } - sender.appendChild(senderInfo); - sender.appendChild(dateDiv); + function showSenderMenu(e, name, email) { + const existingMenu = document.querySelector('.sender-menu'); + if (existingMenu) existingMenu.remove(); - return sender; - } + const menu = document.createElement('div'); + menu.className = 'sender-menu'; - function createRecipients(email) { - const recipients = document.createElement('div'); - recipients.className = 'email-recipients'; + const addContact = document.createElement('div'); + addContact.className = 'sender-menu-item'; + addContact.innerHTML = Icons.addContact + ' <span>Add to Address Book</span>'; + addContact.onclick = () => { + console.log('Add to address book:', name, email); + menu.remove(); + }; - const toDiv = document.createElement('div'); - const toStrong = document.createElement('strong'); - toStrong.textContent = 'To: '; - toDiv.appendChild(toStrong); - toDiv.appendChild(document.createTextNode(email.To || '')); - recipients.appendChild(toDiv); + const composeMail = document.createElement('div'); + composeMail.className = 'sender-menu-item'; + composeMail.innerHTML = Icons.composeMail + ' <span>Compose Mail to</span>'; + composeMail.onclick = () => { + console.log('Compose mail to:', name, email); + menu.remove(); + }; - if (email.CC) { - const ccDiv = document.createElement('div'); - const ccStrong = document.createElement('strong'); - ccStrong.textContent = 'Cc: '; - ccDiv.appendChild(ccStrong); - ccDiv.appendChild(document.createTextNode(email.CC)); - recipients.appendChild(ccDiv); - } + menu.appendChild(addContact); + menu.appendChild(composeMail); - return recipients; - } + document.body.appendChild(menu); - function createAttachments(attachments) { - const container = document.createElement('div'); - container.className = 'email-attachments'; - - const label = document.createElement('strong'); - label.textContent = 'Attachments: '; - container.appendChild(label); - - attachments.forEach(att => { - const link = document.createElement('a'); - link.href = `/api/mail/attachment/${att.ID}`; - link.className = 'attachment'; - link.download = att.Filename; - link.textContent = `${att.Filename} (${att.Size})`; - container.appendChild(link); - }); + const rect = e.target.getBoundingClientRect(); + menu.style.top = rect.bottom + 5 + 'px'; + menu.style.left = rect.left + 'px'; - return container; + setTimeout(() => { + document.addEventListener('click', function closeMenu() { + menu.remove(); + document.removeEventListener('click', closeMenu); + }); + }, 0); } + window.showSenderMenu = showSenderMenu; function createBody(email) { + const container = document.createElement('div'); + container.className = 'email-body-container'; + const body = document.createElement('div'); body.className = 'email-body'; - const displayHTML = prefs.DisplayHTML; - if (displayHTML && email.Body) { - const shadow = ShadowRenderer.render(body, email.Body); - handleRemoteContent(shadow); + const hasHTML = email.Body && email.Body.trim() !== '' && email.Body !== '<pre></pre>'; + + if (hasHTML && viewMode === 'html') { + ShadowRenderer.render(body, email.Body); } else { const pre = document.createElement('pre'); - pre.textContent = email.Body || '[Empty Content]'; + if (wordWrap) { + pre.style.whiteSpace = 'pre-wrap'; + pre.style.wordWrap = 'break-word'; + } else { + pre.style.whiteSpace = 'pre'; + } + + if (email.BodyText && email.BodyText.trim()) { + pre.innerHTML = EmailUtils.linkifyText(email.BodyText); + } else { + pre.textContent = '[Empty Content]'; + } + body.appendChild(pre); } - return body; + container.appendChild(body); + return container; + } + + + + function switchViewMode(mode) { + viewMode = mode; + if (currentEmail) { + renderEmail(currentEmail); + } } - function handleRemoteContent(container) { - const loadOption = prefs.LoadRemoteContent || 'Never'; - - if (loadOption === 'Never') { - // Block all external images - const images = container.querySelectorAll('img'); - images.forEach(img => { - const src = img.getAttribute('src'); - if (src && src.startsWith('http')) { - img.removeAttribute('src'); - img.dataset.src = src; // Store original src - img.alt = '[Remote image blocked]'; - img.style.border = '1px dashed #ccc'; - img.style.padding = '5px'; - img.style.display = 'inline-block'; - } - }); + function toggleWordWrap() { + wordWrap = !wordWrap; + if (currentEmail) { + renderEmail(currentEmail); } - // TODO: Implement "From my contacts" check - // For "Always", images load normally + } + + function showHeaders(email) { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.onclick = () => modal.remove(); + + const dialog = document.createElement('div'); + dialog.className = 'modal-dialog'; + dialog.onclick = (e) => e.stopPropagation(); + + const title = document.createElement('h3'); + title.textContent = 'Message Headers'; + title.className = 'modal-title'; + + const content = document.createElement('pre'); + content.className = 'modal-content'; + content.textContent = email.RawHeaders || 'No headers available'; + + const closeBtn = document.createElement('button'); + closeBtn.textContent = 'Close'; + closeBtn.className = 'btn-close'; + closeBtn.onclick = () => modal.remove(); + + dialog.appendChild(title); + dialog.appendChild(content); + dialog.appendChild(closeBtn); + modal.appendChild(dialog); + document.body.appendChild(modal); } function showError(message) { @@ -303,16 +411,4 @@ document.addEventListener('DOMContentLoaded', function () { error.appendChild(p); preview.appendChild(error); } - - function formatDate(dateString) { - const date = new Date(dateString); - return date.toLocaleString('en-US', { - weekday: 'short', - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - } });
\ No newline at end of file diff --git a/static/js/shadow.js b/static/js/shadow.js index 03d23cf..20c35e1 100644 --- a/static/js/shadow.js +++ b/static/js/shadow.js @@ -1,5 +1,5 @@ const ShadowRenderer = { - render: function (hostElement, htmlContent, options = {}) { + render: function (hostElement, htmlContent) { let shadow = hostElement.shadowRoot; if (!shadow) { shadow = hostElement.attachShadow({ mode: 'open' }); @@ -8,40 +8,32 @@ const ShadowRenderer = { const parser = new DOMParser(); const doc = parser.parseFromString(htmlContent, 'text/html'); - const styles = doc.querySelectorAll('style'); - styles.forEach(style => { - style.textContent = style.textContent.replace(/(^|[\}\s,;])body(?=[\s,\.\{])/gi, '$1.mail-body-content'); - }); - - const wrapper = document.createElement('div'); - wrapper.className = 'mail-body-content'; + shadow.innerHTML = ''; - if (doc.body) { - Array.from(doc.body.attributes).forEach(attr => { - if (attr.name === 'class') { - if (attr.value) wrapper.classList.add(...attr.value.split(' ')); - } else { - wrapper.setAttribute(attr.name, attr.value); - } + if (doc.head) { + doc.head.querySelectorAll('style').forEach(style => { + const styleClone = style.cloneNode(true); + let css = styleClone.textContent; + css = css.replace(/\bbody\b/g, ':host'); + styleClone.textContent = css; + shadow.appendChild(styleClone); }); - if (doc.body.bgColor) wrapper.style.backgroundColor = doc.body.bgColor; - while (doc.body.firstChild) wrapper.appendChild(doc.body.firstChild); + doc.head.querySelectorAll('link[rel="stylesheet"]').forEach(link => { + shadow.appendChild(link.cloneNode(true)); + }); } - shadow.innerHTML = ''; - if (doc.head) { - while (doc.head.firstChild) shadow.appendChild(doc.head.firstChild); + if (doc.body) { + while (doc.body.firstChild) { + shadow.appendChild(doc.body.firstChild); + } } - shadow.appendChild(wrapper); - const defaultStyle = document.createElement('style'); - defaultStyle.textContent = ` - :host { display: block; overflow: auto; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; } - img { max-width: 100%; height: auto; } - a { color: #1a73e8; } - `; - shadow.prepend(defaultStyle); + + setTimeout(() => { + EmailUtils.adjustContrast(shadow); + }, 100); return shadow; } -}; +};
\ No newline at end of file diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..edfa2e9 --- /dev/null +++ b/static/js/utils.js @@ -0,0 +1,113 @@ +const EmailUtils = { + getProfilePicture: async function (email, name) { + const gravatarUrl = await this.checkGravatar(email); + if (gravatarUrl) return gravatarUrl; + + let domain = email.split('@')[1]; + + // Remove all subdomains from the domain + const domainParts = domain.split('.'); + if (domainParts.length > 2) { + domainParts.shift(); + domain = domainParts.join('.'); + } + + return `https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=http://${domain}&size=128`; + }, + + checkGravatar: async function (email) { + const hash = await this.sha256(email.toLowerCase().trim()); + const testUrl = `https://www.gravatar.com/avatar/${hash}?s=128&d=404`; + + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(testUrl); + img.onerror = () => resolve(null); + img.src = testUrl; + }); + }, + + sha256: async function (message) { + const msgBuffer = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + }, + + getInitials: function (name, email) { + if (name && name !== email) { + const parts = name.trim().split(' '); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); + } + return email.substring(0, 2).toUpperCase(); + }, + + formatEmailDisplay: function (name, email, showAddress) { + if (!name || name === email) { + return email; + } + + if (showAddress) { + return `${name} <${email}>`; + } + + return name; + }, + + linkifyText: function (text) { + const urlRegex = /(https?:\/\/[^\s]+)/g; + const emailRegex = /([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/g; + + return text + .replace(urlRegex, '<a href="$1" target="_blank" style="color: var(--accent-primary); text-decoration: underline;">$1</a>') + .replace(emailRegex, '<a href="mailto:$1" style="color: var(--accent-primary); text-decoration: underline;">$1</a>'); + }, + + adjustContrast: function (container) { + const allElements = container.querySelectorAll('*'); + + allElements.forEach(el => { + const style = window.getComputedStyle(el); + const color = style.color; + const bgColor = style.backgroundColor; + + const textLum = this.getLuminance(color); + const bgLum = this.getLuminance(bgColor); + + const isTransparent = bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent'; + + let actualBgLum = bgLum; + if (isTransparent) { + let parent = el.parentElement; + while (parent && (window.getComputedStyle(parent).backgroundColor === 'rgba(0, 0, 0, 0)' || + window.getComputedStyle(parent).backgroundColor === 'transparent')) { + parent = parent.parentElement; + } + if (parent) { + actualBgLum = this.getLuminance(window.getComputedStyle(parent).backgroundColor); + } + } + + if (actualBgLum > 0.8 && textLum > 0.55) { + el.style.setProperty('color', '#000000', 'important'); + } else if (actualBgLum < 0.3 && textLum < 0.45) { + el.style.setProperty('color', '#e8e8f0', 'important'); + } + }); + }, + + getLuminance: function (color) { + const rgb = color.match(/\d+/g); + if (!rgb || rgb.length < 3) return 1; + + const [r, g, b] = rgb.map(val => { + const v = val / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } +};
\ No newline at end of file diff --git a/templates/mail/folder.django b/templates/mail/folder.django index 26fd20c..6ccebe6 100644 --- a/templates/mail/folder.django +++ b/templates/mail/folder.django @@ -6,6 +6,9 @@ {% block scripts %} {{ block.super }} + <script src="{% static 'js/dropdown.js' %}"></script> + <script src="{% static 'js/utils.js' %}"></script> + <script src="{% static 'js/icons.js' %}"></script> <script src="{% static 'js/shadow.js' %}"></script> <script src="{% static 'js/mail.js' %}"></script> <script src="{% static 'js/filters.js' %}"></script> diff --git a/types/email.go b/types/email.go index e217c6f..7ad26e6 100644 --- a/types/email.go +++ b/types/email.go @@ -33,6 +33,7 @@ type EmailMessage struct { Date time.Time BodyText string BodyHTML string + RawHeaders string Size uint32 InReplyTo string IsRead bool diff --git a/utils/email/messages.go b/utils/email/messages.go index cedbec5..38d9f3a 100644 --- a/utils/email/messages.go +++ b/utils/email/messages.go @@ -1,6 +1,7 @@ package email import ( + "bytes" "fmt" "io" "lain/types" @@ -17,14 +18,14 @@ func SelectFolder(client *types.EmailClient, folderName string) (*imap.MailboxSt return mbox, nil } -func FetchMessages(client *types.EmailClient, folderName string, limit uint32) ([]*types.EmailMessage, error) { - mbox, err := SelectFolder(client, folderName) +func FetchMessages(client *types.EmailClient, folderName string, limit uint32) ([]types.EmailMessage, error) { + mbox, err := client.Select(folderName, false) if err != nil { return nil, err } if mbox.Messages == 0 { - return []*types.EmailMessage{}, nil + return []types.EmailMessage{}, nil } from := uint32(1) @@ -39,14 +40,24 @@ func FetchMessages(client *types.EmailClient, folderName string, limit uint32) ( messages := make(chan *imap.Message, 10) done := make(chan error, 1) - section := &imap.BodySectionName{} - items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, imap.FetchRFC822Size, section.FetchItem()} + section := &imap.BodySectionName{Peek: true} + headerSection := &imap.BodySectionName{Peek: true} + + items := []imap.FetchItem{ + imap.FetchEnvelope, + imap.FetchFlags, + imap.FetchUid, + imap.FetchRFC822Size, + section.FetchItem(), + headerSection.FetchItem(), + } go func() { done <- client.Fetch(seqset, items, messages) }() - var result []*types.EmailMessage + var result []types.EmailMessage + for msg := range messages { if msg == nil { continue @@ -57,11 +68,11 @@ func FetchMessages(client *types.EmailClient, folderName string, limit uint32) ( continue } - result = append(result, emailMsg) + result = append(result, *emailMsg) } if err := <-done; err != nil { - return nil, fmt.Errorf("failed to fetch messages: %w", err) + return nil, err } return result, nil @@ -72,13 +83,28 @@ func parseMessage(msg *imap.Message) (*types.EmailMessage, error) { return nil, fmt.Errorf("message envelope is nil") } - section := &imap.BodySectionName{} + section := &imap.BodySectionName{Peek: true} bodyReader := msg.GetBody(section) if bodyReader == nil { return nil, fmt.Errorf("message body is nil") } - mr, err := mail.CreateReader(bodyReader) + fullMessage, err := io.ReadAll(bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to read message: %w", err) + } + + headerEnd := bytes.Index(fullMessage, []byte("\r\n\r\n")) + if headerEnd == -1 { + headerEnd = bytes.Index(fullMessage, []byte("\n\n")) + } + + var rawHeaders string + if headerEnd != -1 { + rawHeaders = string(fullMessage[:headerEnd]) + } + + mr, err := mail.CreateReader(bytes.NewReader(fullMessage)) if err != nil { return nil, fmt.Errorf("failed to create mail reader: %w", err) } @@ -100,9 +126,10 @@ func parseMessage(msg *imap.Message) (*types.EmailMessage, error) { contentType, _, _ := h.ContentType() body, _ := io.ReadAll(part.Body) - if contentType == "text/plain" { + switch contentType { + case "text/plain": bodyText = string(body) - } else if contentType == "text/html" { + case "text/html": bodyHTML = string(body) } @@ -127,7 +154,12 @@ func parseMessage(msg *imap.Message) (*types.EmailMessage, error) { var toList []string for _, addr := range msg.Envelope.To { - toList = append(toList, addr.MailboxName+"@"+addr.HostName) + email := addr.MailboxName + "@" + addr.HostName + if addr.PersonalName != "" { + toList = append(toList, addr.PersonalName+" <"+email+">") + } else { + toList = append(toList, email) + } } var ccList []string @@ -176,6 +208,7 @@ func parseMessage(msg *imap.Message) (*types.EmailMessage, error) { Date: msg.Envelope.Date, BodyText: bodyText, BodyHTML: bodyHTML, + RawHeaders: rawHeaders, Size: msg.Size, InReplyTo: msg.Envelope.InReplyTo, IsRead: isRead, @@ -196,7 +229,7 @@ func MarkAsRead(client *types.EmailClient, folderName string, uid uint32) error seqSet.AddNum(uid) item := imap.FormatFlagsOp(imap.AddFlags, true) - flags := []interface{}{imap.SeenFlag} + flags := []any{imap.SeenFlag} if err := client.UidStore(seqSet, item, flags, nil); err != nil { return fmt.Errorf("failed to mark as read: %w", err) @@ -220,7 +253,7 @@ func ToggleFlag(client *types.EmailClient, folderName string, uid uint32, isFlag item = imap.FormatFlagsOp(imap.AddFlags, true) } - flags := []interface{}{imap.FlaggedFlag} + flags := []any{imap.FlaggedFlag} if err := client.UidStore(seqSet, item, flags, nil); err != nil { return fmt.Errorf("failed to toggle flag: %w", err) diff --git a/utils/format/html.go b/utils/format/html.go index d976cb8..60e2e85 100644 --- a/utils/format/html.go +++ b/utils/format/html.go @@ -7,18 +7,10 @@ import ( ) func SanitizeHTML(htmlContent string) string { - // Remove dangerous tags htmlContent = removeDangerousTags(htmlContent) - - // Remove inline event handlers htmlContent = removeEventHandlers(htmlContent) - - // Remove javascript: protocol htmlContent = removeJavascriptProtocol(htmlContent) - - // Sanitize styles htmlContent = sanitizeStyles(htmlContent) - return htmlContent } @@ -49,7 +41,6 @@ func removeJavascriptProtocol(html string) string { } func sanitizeStyles(html string) string { - // Remove dangerous CSS properties dangerousStyles := []string{"behavior", "expression", "binding", "import", "moz-binding"} for _, style := range dangerousStyles { @@ -127,6 +118,46 @@ func StripHTML(html string) string { return strings.TrimSpace(strings.Join(cleanLines, " ")) } +func HTMLToPlainText(htmlContent string) string { + text := htmlContent + + text = regexp.MustCompile(`(?i)<style[^>]*>[\s\S]*?</style>`).ReplaceAllString(text, "") + text = regexp.MustCompile(`(?i)<script[^>]*>[\s\S]*?</script>`).ReplaceAllString(text, "") + text = regexp.MustCompile(`(?i)<head[^>]*>[\s\S]*?</head>`).ReplaceAllString(text, "") + text = regexp.MustCompile(`(?i)<title[^>]*>[\s\S]*?</title>`).ReplaceAllString(text, "") + + text = regexp.MustCompile(`(?i)<br\s*/?>`).ReplaceAllString(text, "\n") + text = regexp.MustCompile(`(?i)</p>`).ReplaceAllString(text, "\n\n") + text = regexp.MustCompile(`(?i)</div>`).ReplaceAllString(text, "\n") + text = regexp.MustCompile(`(?i)</tr>`).ReplaceAllString(text, "\n") + text = regexp.MustCompile(`(?i)</h[1-6]>`).ReplaceAllString(text, "\n\n") + text = regexp.MustCompile(`(?i)</li>`).ReplaceAllString(text, "\n") + + text = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(text, "") + + text = strings.ReplaceAll(text, " ", " ") + text = strings.ReplaceAll(text, "<", "<") + text = strings.ReplaceAll(text, ">", ">") + text = strings.ReplaceAll(text, "&", "&") + text = strings.ReplaceAll(text, """, "\"") + text = strings.ReplaceAll(text, "'", "'") + text = strings.ReplaceAll(text, "'", "'") + + text = regexp.MustCompile(`\n\s*\n\s*\n+`).ReplaceAllString(text, "\n\n") + text = regexp.MustCompile(`[ \t]+`).ReplaceAllString(text, " ") + + lines := strings.Split(text, "\n") + var cleanLines []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + cleanLines = append(cleanLines, trimmed) + } + } + + return strings.TrimSpace(strings.Join(cleanLines, "\n")) +} + func DecodeHTML(text string) string { return html.UnescapeString(text) } |
