summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--jobs/emails.go1
-rw-r--r--models/email.go56
-rw-r--r--services/emails.go48
-rw-r--r--static/css/main.css685
-rw-r--r--static/js/dropdown.js18
-rw-r--r--static/js/icons.js24
-rw-r--r--static/js/mail.js390
-rw-r--r--static/js/shadow.js50
-rw-r--r--static/js/utils.js113
-rw-r--r--templates/mail/folder.django3
-rw-r--r--types/email.go1
-rw-r--r--utils/email/messages.go63
-rw-r--r--utils/format/html.go49
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, "&nbsp;", " ")
+ text = strings.ReplaceAll(text, "&lt;", "<")
+ text = strings.ReplaceAll(text, "&gt;", ">")
+ text = strings.ReplaceAll(text, "&amp;", "&")
+ text = strings.ReplaceAll(text, "&quot;", "\"")
+ text = strings.ReplaceAll(text, "&#39;", "'")
+ text = strings.ReplaceAll(text, "&#x27;", "'")
+
+ 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)
}