From 6f69e41b7ff4928dfa43783f50215becc88e3faf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 09:07:30 +0000 Subject: [PATCH 01/10] Initial plan From 2f1669f33543a77a81be954e1f4f5d7d010c5cf6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 09:11:02 +0000 Subject: [PATCH 02/10] Initial planning for mail feature implementation Co-authored-by: asim <17530+asim@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 99af7ab..91ac2d0 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,11 @@ require ( github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b github.com/google/uuid v1.6.0 github.com/mmcdole/gofeed v1.3.0 + github.com/mrz1836/go-sanitize v1.5.3 + github.com/philippgille/chromem-go v0.7.0 github.com/piquette/finance-go v1.1.0 + golang.org/x/crypto v0.40.0 + golang.org/x/net v0.42.0 google.golang.org/api v0.243.0 ) @@ -28,16 +32,12 @@ require ( github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/mrz1836/go-sanitize v1.5.3 // indirect - github.com/philippgille/chromem-go v0.7.0 // indirect github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.36.0 // indirect go.opentelemetry.io/otel/metric v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/net v0.42.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.27.0 // indirect diff --git a/go.sum b/go.sum index 0b1b630..b2f570b 100644 --- a/go.sum +++ b/go.sum @@ -57,9 +57,8 @@ github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9Nz github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= From dece7148732fc56479a3a29ef6651a9e20fa79fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 09:14:23 +0000 Subject: [PATCH 03/10] Add mail functionality with inbox, compose, and home integration Co-authored-by: asim <17530+asim@users.noreply.github.com> --- README.md | 3 +- app/app.go | 1 + app/html/index.html | 3 +- app/html/mu.css | 5 +- home/home.go | 17 ++- mail/mail.go | 253 ++++++++++++++++++++++++++++++++++++++++++++ main.go | 8 ++ 7 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 mail/mail.go diff --git a/README.md b/README.md index c9d02ff..d62117a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # mu -One app for AI chat, news and video +One app for mail, chat, news and video # Overview @@ -14,6 +14,7 @@ Starting with: - [x] API - Basic API - [x] App - Installable PWA +- [x] Mail - Internal mail server - [x] Chat - LLM based chat UI - [x] News - Latest news headlines - [x] Video - Video search interface diff --git a/app/app.go b/app/app.go index 5d614ae..7fbd8ef 100644 --- a/app/app.go +++ b/app/app.go @@ -48,6 +48,7 @@ var Template = `
 
Mu
-
An app for AI chat, news and video
+
One app for mail, chat, news and video
Home + Mail Chat News Video diff --git a/app/html/mu.css b/app/html/mu.css index 250f3e7..d21a76c 100644 --- a/app/html/mu.css +++ b/app/html/mu.css @@ -369,6 +369,9 @@ td { background: white; width: calc(100vw - 40px); } + #nav img { + margin-right: 0; + } #container { height: calc(100vh - 175px); overflow-y: scroll; @@ -384,7 +387,7 @@ td { .post-show { margin-top: 10px; } - .card, #reminder, #video { + .card, #reminder, #video, #mail { float: none; display: block; width: calc(100vw - 85px); diff --git a/home/home.go b/home/home.go index b32752d..3e30252 100644 --- a/home/home.go +++ b/home/home.go @@ -6,17 +6,21 @@ import ( "strings" "mu/app" + "mu/auth" + "mu/mail" "mu/news" "mu/video" ) var Template = `
%s
` -func Cards(news, markets, reminder, latest string) []string { +func Cards(mailStatus, news, markets, reminder, latest string) []string { + mailStatus += app.Link("More", "/mail") news += app.Link("More", "/news") latest += app.Link("More", "/video") cards := []string{ + app.Card("mail", "Mail", mailStatus), app.Card("news", "News", news), app.Card("reminder", "Reminder", reminder), app.Card("markets", "Markets", markets), @@ -26,13 +30,22 @@ func Cards(news, markets, reminder, latest string) []string { } func Handler(w http.ResponseWriter, r *http.Request) { + // get the session + sess, err := auth.GetSession(r) + if err != nil { + http.Error(w, "Unauthorized", 401) + return + } + + username := sess.Account + mailStatus := mail.LatestMail(username) headlines := news.Headlines() markets := news.Markets() reminder := news.Reminder() latest := video.Latest() // create homepage - cards := strings.Join(Cards(headlines, markets, reminder, latest), "\n") + cards := strings.Join(Cards(mailStatus, headlines, markets, reminder, latest), "\n") homepage := fmt.Sprintf(Template, cards) // render html diff --git a/mail/mail.go b/mail/mail.go new file mode 100644 index 0000000..732dceb --- /dev/null +++ b/mail/mail.go @@ -0,0 +1,253 @@ +package mail + +import ( + "encoding/json" + "fmt" + "net/http" + "sort" + "sync" + "time" + + "mu/app" + "mu/auth" + "mu/data" +) + +var mutex sync.RWMutex + +// Message represents a mail message +type Message struct { + ID string `json:"id"` + From string `json:"from"` + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` + Sent time.Time `json:"sent"` + Read bool `json:"read"` +} + +// all messages stored by recipient +var messages = map[string][]*Message{} + +// cached html for pages +var html string + +var ComposeTemplate = ` +
+

Compose Mail

+
+
+
+
+ +
+
+ Back to Inbox +
+` + +func init() { + // load messages from disk + b, _ := data.Load("messages.json") + if b != nil { + json.Unmarshal(b, &messages) + } +} + +// Load initializes the mail system +func Load() { + fmt.Println("Mail system loaded") +} + +// SendMessage sends a message from one user to another +func SendMessage(from, to, subject, body string) error { + mutex.Lock() + defer mutex.Unlock() + + // create message + msg := &Message{ + ID: fmt.Sprintf("%s-%d", from, time.Now().UnixNano()), + From: from, + To: to, + Subject: subject, + Body: body, + Sent: time.Now(), + Read: false, + } + + // add to recipient's inbox + if messages[to] == nil { + messages[to] = []*Message{} + } + messages[to] = append(messages[to], msg) + + // save to disk + data.SaveJSON("messages.json", messages) + + // index the message for search + go func() { + data.Index(msg.ID, map[string]string{ + "type": "mail", + "from": from, + "to": to, + "subject": subject, + }, body) + }() + + return nil +} + +// GetInbox retrieves all messages for a user +func GetInbox(username string) []*Message { + mutex.RLock() + defer mutex.RUnlock() + + inbox := messages[username] + if inbox == nil { + return []*Message{} + } + + // sort by most recent first + sorted := make([]*Message, len(inbox)) + copy(sorted, inbox) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Sent.After(sorted[j].Sent) + }) + + return sorted +} + +// MarkAsRead marks a message as read +func MarkAsRead(username, messageID string) { + mutex.Lock() + defer mutex.Unlock() + + inbox := messages[username] + for _, msg := range inbox { + if msg.ID == messageID { + msg.Read = true + data.SaveJSON("messages.json", messages) + break + } + } +} + +// GetUnreadCount returns the number of unread messages +func GetUnreadCount(username string) int { + mutex.RLock() + defer mutex.RUnlock() + + inbox := messages[username] + count := 0 + for _, msg := range inbox { + if !msg.Read { + count++ + } + } + return count +} + +// LatestMail returns HTML for the latest mail status +func LatestMail(username string) string { + unread := GetUnreadCount(username) + if unread > 0 { + return fmt.Sprintf(`You've got mail! (%d new)`, unread) + } + return "No new messages" +} + +// Handler handles mail requests +func Handler(w http.ResponseWriter, r *http.Request) { + // get the session + sess, err := auth.GetSession(r) + if err != nil { + http.Error(w, "Unauthorized", 401) + return + } + + username := sess.Account + + // POST - send a new message + if r.Method == "POST" { + r.ParseForm() + + // check if it's a compose or mark as read action + action := r.Form.Get("action") + + if action == "read" { + messageID := r.Form.Get("id") + MarkAsRead(username, messageID) + http.Redirect(w, r, "/mail", 302) + return + } + + // it's a send message action + to := r.Form.Get("to") + subject := r.Form.Get("subject") + body := r.Form.Get("body") + + if len(to) == 0 || len(subject) == 0 || len(body) == 0 { + http.Error(w, "Missing required fields", 400) + return + } + + err := SendMessage(username, to, subject, body) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + + // redirect back to inbox + http.Redirect(w, r, "/mail", 302) + return + } + + // GET - show inbox or compose form + if r.URL.Query().Get("compose") == "true" { + html := app.RenderHTML("Compose Mail", "Send a message", ComposeTemplate) + w.Write([]byte(html)) + return + } + + // show inbox + inbox := GetInbox(username) + + var content string + content += `
` + content += `

Mail

` + content += fmt.Sprintf(`Compose

`) + + if len(inbox) == 0 { + content += `

No messages in your inbox.

` + } else { + content += `
` + for _, msg := range inbox { + style := "" + if !msg.Read { + style = "font-weight: bold;" + } + + content += fmt.Sprintf(` +
+
+
From: %s
+
%s
+
%s
+
%s
+
+ + + +
+
+
+ `, style, msg.From, msg.Subject, msg.Body, app.TimeAgo(msg.Sent), msg.ID) + } + content += `
` + } + + content += `
` + + html := app.RenderHTML("Mail", "Your inbox", content) + w.Write([]byte(html)) +} diff --git a/main.go b/main.go index 9f8aefc..15ac3aa 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "mu/auth" "mu/chat" "mu/home" + "mu/mail" "mu/news" "mu/video" ) @@ -40,6 +41,9 @@ func main() { // load the chat //chat.Load() + // load the mail + mail.Load() + // load the news news.Load() @@ -50,6 +54,7 @@ func main() { "/video": true, "/news": true, "/chat": true, + "/mail": true, "/home": true, "/logout": true, "/session": true, @@ -64,6 +69,9 @@ func main() { // serve chat http.HandleFunc("/chat", chat.Handler) + // serve mail + http.HandleFunc("/mail", mail.Handler) + // serve the home screen http.HandleFunc("/home", home.Handler) From c4e91d9772ebf40959ddc618abc449a01d911152 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 09:30:05 +0000 Subject: [PATCH 04/10] Security fix: Remove mail indexing to prevent data leakage and add safeguards Co-authored-by: asim <17530+asim@users.noreply.github.com> --- chat/chat.go | 8 ++++++++ mail/mail.go | 12 +++--------- video/video.go | 6 ++++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/chat/chat.go b/chat/chat.go index e35098d..7f06eac 100644 --- a/chat/chat.go +++ b/chat/chat.go @@ -216,6 +216,10 @@ func Handler(w http.ResponseWriter, r *http.Request) { q := fmt.Sprintf("%v", form["prompt"]) + // Search index for RAG context + // NOTE: We explicitly pass nil here to search all indexed content, + // but mail is intentionally NOT indexed for privacy/security reasons. + // If mail indexing is ever added, this must be updated to filter it out. res, err := data.Search(q, 10, nil) if err != nil { http.Error(w, err.Error(), 500) @@ -225,6 +229,10 @@ func Handler(w http.ResponseWriter, r *http.Request) { var rag []string for _, val := range res { + // Security: Skip any mail content if it somehow got indexed + if val.Metadata["type"] == "mail" { + continue + } if len(val.Content) > 512 { val.Content = val.Content[:512] } diff --git a/mail/mail.go b/mail/mail.go index 732dceb..07287ea 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -84,15 +84,9 @@ func SendMessage(from, to, subject, body string) error { // save to disk data.SaveJSON("messages.json", messages) - // index the message for search - go func() { - data.Index(msg.ID, map[string]string{ - "type": "mail", - "from": from, - "to": to, - "subject": subject, - }, body) - }() + // NOTE: We intentionally do NOT index mail messages for security reasons. + // Mail is private communication between users and should not be searchable + // in a shared index that could potentially leak private data. return nil } diff --git a/video/video.go b/video/video.go index 64b5a5d..18c2a73 100644 --- a/video/video.go +++ b/video/video.go @@ -222,8 +222,10 @@ func loadVideos() { mutex.Lock() data.Save("videos.html", vidHtml) data.Save("videos.json", vidJson) - data.Save("latest.html", latest[0].Html) - latestHtml = latest[0].Html + if len(latest) > 0 { + data.Save("latest.html", latest[0].Html) + latestHtml = latest[0].Html + } videos = vids videosHtml = vidHtml mutex.Unlock() From 498dca614460188f5483fecf468316a9b57245d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 09:43:32 +0000 Subject: [PATCH 05/10] Add client-side mail search with sessionStorage and XSS protection Co-authored-by: asim <17530+asim@users.noreply.github.com> --- auth/auth.go | 8 +++++++ mail/mail.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index c46a3f0..c5b97b6 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -181,3 +181,11 @@ func ValidateToken(tk string) error { } return nil } + +// AccountExists checks if an account with the given ID exists +func AccountExists(id string) bool { + mutex.Lock() + defer mutex.Unlock() + _, exists := accounts[id] + return exists +} diff --git a/mail/mail.go b/mail/mail.go index 07287ea..79ceed5 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -3,6 +3,7 @@ package mail import ( "encoding/json" "fmt" + stdhtml "html" "net/http" "sort" "sync" @@ -61,6 +62,11 @@ func Load() { // SendMessage sends a message from one user to another func SendMessage(from, to, subject, body string) error { + // Check if recipient exists before sending + if !auth.AccountExists(to) { + return fmt.Errorf("recipient user '%s' does not exist", to) + } + mutex.Lock() defer mutex.Unlock() @@ -87,6 +93,7 @@ func SendMessage(from, to, subject, body string) error { // NOTE: We intentionally do NOT index mail messages for security reasons. // Mail is private communication between users and should not be searchable // in a shared index that could potentially leak private data. + // Instead, mail is searchable client-side in the browser using local storage. return nil } @@ -203,6 +210,14 @@ func Handler(w http.ResponseWriter, r *http.Request) { return } + // Check if JSON response is requested + if ct := r.Header.Get("Content-Type"); ct == "application/json" || r.URL.Query().Get("format") == "json" { + inbox := GetInbox(username) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(inbox) + return + } + // show inbox inbox := GetInbox(username) @@ -211,22 +226,33 @@ func Handler(w http.ResponseWriter, r *http.Request) { content += `

Mail

` content += fmt.Sprintf(`Compose

`) + // Add search box for client-side filtering + content += ` +
+ +
` + if len(inbox) == 0 { content += `

No messages in your inbox.

` } else { - content += `
` + content += `
` for _, msg := range inbox { style := "" if !msg.Read { style = "font-weight: bold;" } + // HTML escape all user-provided content to prevent XSS + fromEscaped := stdhtml.EscapeString(msg.From) + subjectEscaped := stdhtml.EscapeString(msg.Subject) + bodyEscaped := stdhtml.EscapeString(msg.Body) + content += fmt.Sprintf(` -
+
From: %s
%s
-
%s
+
%s
%s
@@ -235,11 +261,45 @@ func Handler(w http.ResponseWriter, r *http.Request) {
- `, style, msg.From, msg.Subject, msg.Body, app.TimeAgo(msg.Sent), msg.ID) + `, fromEscaped, subjectEscaped, bodyEscaped, style, fromEscaped, subjectEscaped, bodyEscaped, app.TimeAgo(msg.Sent), msg.ID) } content += `
` } + // Add JavaScript for client-side search + content += ` +` + content += `
` html := app.RenderHTML("Mail", "Your inbox", content) From 6403457b1c50b88d615d28b979ff8941b17fa1d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 09:44:53 +0000 Subject: [PATCH 06/10] Fix JSON content-type check to use query parameter only Co-authored-by: asim <17530+asim@users.noreply.github.com> --- mail/mail.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mail/mail.go b/mail/mail.go index 79ceed5..53abb91 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -210,8 +210,8 @@ func Handler(w http.ResponseWriter, r *http.Request) { return } - // Check if JSON response is requested - if ct := r.Header.Get("Content-Type"); ct == "application/json" || r.URL.Query().Get("format") == "json" { + // Check if JSON response is requested via query parameter + if r.URL.Query().Get("format") == "json" { inbox := GetInbox(username) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(inbox) From 7d2453e3c4f5c8b775fff6a0e0484647733189ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:27:46 +0000 Subject: [PATCH 07/10] Changes before error encountered Co-authored-by: asim <17530+asim@users.noreply.github.com> --- go.mod | 8 +- go.sum | 9 +++ mail/mail.go | 213 +++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 203 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 91ac2d0..e262e52 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,8 @@ require ( github.com/mrz1836/go-sanitize v1.5.3 github.com/philippgille/chromem-go v0.7.0 github.com/piquette/finance-go v1.1.0 - golang.org/x/crypto v0.40.0 - golang.org/x/net v0.42.0 + golang.org/x/crypto v0.43.0 + golang.org/x/net v0.45.0 google.golang.org/api v0.243.0 ) @@ -39,8 +39,8 @@ require ( go.opentelemetry.io/otel/metric v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect diff --git a/go.sum b/go.sum index b2f570b..66d7e40 100644 --- a/go.sum +++ b/go.sum @@ -75,21 +75,30 @@ go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKr go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/api v0.243.0 h1:sw+ESIJ4BVnlJcWu9S+p2Z6Qq1PjG77T8IJ1xtp4jZQ= google.golang.org/api v0.243.0/go.mod h1:GE4QtYfaybx1KmeHMdBnNnyLzBZCVihGBXAmJu/uUr8= diff --git a/mail/mail.go b/mail/mail.go index 53abb91..aad66b1 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -1,10 +1,17 @@ package mail import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" + "io" stdhtml "html" "net/http" + "os" "sort" "sync" "time" @@ -16,15 +23,32 @@ import ( var mutex sync.RWMutex +// encryption key derived from environment or default +var encryptionKey []byte + +func init() { + // Get encryption key from environment or generate a default one + keyStr := os.Getenv("MAIL_ENCRYPTION_KEY") + if keyStr == "" { + // Use a default key for development (in production, this should be set via env) + keyStr = "mu-mail-encryption-key-change-me-in-production" + } + // Derive a 32-byte key from the string + hash := sha256.Sum256([]byte(keyStr)) + encryptionKey = hash[:] +} + // Message represents a mail message +// Body is stored encrypted on the server type Message struct { - ID string `json:"id"` - From string `json:"from"` - To string `json:"to"` - Subject string `json:"subject"` - Body string `json:"body"` - Sent time.Time `json:"sent"` - Read bool `json:"read"` + ID string `json:"id"` + From string `json:"from"` + To string `json:"to"` + Subject string `json:"subject"` + EncryptedBody string `json:"encrypted_body"` // Base64 encoded encrypted body + Sent time.Time `json:"sent"` + Read bool `json:"read"` + DeleteOnRead bool `json:"delete_on_read"` // Auto-delete when marked as read } // all messages stored by recipient @@ -48,6 +72,16 @@ var ComposeTemplate = ` ` func init() { + // Get encryption key from environment or generate a default one + keyStr := os.Getenv("MAIL_ENCRYPTION_KEY") + if keyStr == "" { + // Use a default key for development (in production, this should be set via env) + keyStr = "mu-mail-encryption-key-change-me-in-production" + } + // Derive a 32-byte key from the string + hash := sha256.Sum256([]byte(keyStr)) + encryptionKey = hash[:] + // load messages from disk b, _ := data.Load("messages.json") if b != nil { @@ -55,30 +89,90 @@ func init() { } } +// encryptBody encrypts the message body using AES-GCM +func encryptBody(plaintext string) (string, error) { + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// decryptBody decrypts the message body using AES-GCM +func decryptBody(encrypted string) (string, error) { + ciphertext, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} + // Load initializes the mail system func Load() { fmt.Println("Mail system loaded") } // SendMessage sends a message from one user to another +// The message body is encrypted before storage func SendMessage(from, to, subject, body string) error { // Check if recipient exists before sending if !auth.AccountExists(to) { return fmt.Errorf("recipient user '%s' does not exist", to) } + // Encrypt the message body + encryptedBody, err := encryptBody(body) + if err != nil { + return fmt.Errorf("failed to encrypt message: %v", err) + } + mutex.Lock() defer mutex.Unlock() // create message msg := &Message{ - ID: fmt.Sprintf("%s-%d", from, time.Now().UnixNano()), - From: from, - To: to, - Subject: subject, - Body: body, - Sent: time.Now(), - Read: false, + ID: fmt.Sprintf("%s-%d", from, time.Now().UnixNano()), + From: from, + To: to, + Subject: subject, + EncryptedBody: encryptedBody, + Sent: time.Now(), + Read: false, + DeleteOnRead: true, // Enable auto-delete on read } // add to recipient's inbox @@ -93,12 +187,12 @@ func SendMessage(from, to, subject, body string) error { // NOTE: We intentionally do NOT index mail messages for security reasons. // Mail is private communication between users and should not be searchable // in a shared index that could potentially leak private data. - // Instead, mail is searchable client-side in the browser using local storage. + // Messages are encrypted at rest and auto-deleted when read. return nil } -// GetInbox retrieves all messages for a user +// GetInbox retrieves all messages for a user and decrypts them func GetInbox(username string) []*Message { mutex.RLock() defer mutex.RUnlock() @@ -118,21 +212,59 @@ func GetInbox(username string) []*Message { return sorted } -// MarkAsRead marks a message as read +// GetDecryptedBody decrypts and returns the body of a message +func GetDecryptedBody(msg *Message) string { + if msg.EncryptedBody == "" { + return "" + } + body, err := decryptBody(msg.EncryptedBody) + if err != nil { + fmt.Printf("Error decrypting message %s: %v\n", msg.ID, err) + return "[Error decrypting message]" + } + return body +} + +// MarkAsRead marks a message as read and deletes it if DeleteOnRead is true func MarkAsRead(username, messageID string) { mutex.Lock() defer mutex.Unlock() inbox := messages[username] - for _, msg := range inbox { + for i, msg := range inbox { if msg.ID == messageID { - msg.Read = true + if msg.DeleteOnRead { + // Store-and-forward: Delete the message from server after reading + messages[username] = append(inbox[:i], inbox[i+1:]...) + fmt.Printf("Message %s deleted from server after being read by %s\n", messageID, username) + } else { + // Legacy behavior: just mark as read + msg.Read = true + } data.SaveJSON("messages.json", messages) break } } } +// DeleteMessage allows a user to delete their own message +// This only works for messages they received, not messages they sent +func DeleteMessage(username, messageID string) error { + mutex.Lock() + defer mutex.Unlock() + + inbox := messages[username] + for i, msg := range inbox { + if msg.ID == messageID { + // Remove the message + messages[username] = append(inbox[:i], inbox[i+1:]...) + data.SaveJSON("messages.json", messages) + return nil + } + } + return fmt.Errorf("message not found") +} + // GetUnreadCount returns the number of unread messages func GetUnreadCount(username string) int { mutex.RLock() @@ -213,8 +345,33 @@ func Handler(w http.ResponseWriter, r *http.Request) { // Check if JSON response is requested via query parameter if r.URL.Query().Get("format") == "json" { inbox := GetInbox(username) + + // Create response with decrypted bodies for client-side search + type MessageResponse struct { + ID string `json:"id"` + From string `json:"from"` + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` // Decrypted for client + Sent time.Time `json:"sent"` + Read bool `json:"read"` + } + + var response []MessageResponse + for _, msg := range inbox { + response = append(response, MessageResponse{ + ID: msg.ID, + From: msg.From, + To: msg.To, + Subject: msg.Subject, + Body: GetDecryptedBody(msg), + Sent: msg.Sent, + Read: msg.Read, + }) + } + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(inbox) + json.NewEncoder(w).Encode(response) return } @@ -242,10 +399,19 @@ func Handler(w http.ResponseWriter, r *http.Request) { style = "font-weight: bold;" } + // Decrypt the message body + decryptedBody := GetDecryptedBody(msg) + // HTML escape all user-provided content to prevent XSS fromEscaped := stdhtml.EscapeString(msg.From) subjectEscaped := stdhtml.EscapeString(msg.Subject) - bodyEscaped := stdhtml.EscapeString(msg.Body) + bodyEscaped := stdhtml.EscapeString(decryptedBody) + + // Info text about auto-deletion + deleteInfo := "" + if msg.DeleteOnRead { + deleteInfo = `
⚠️ This message will be deleted from the server when marked as read
` + } content += fmt.Sprintf(`
@@ -254,14 +420,15 @@ func Handler(w http.ResponseWriter, r *http.Request) {
%s
%s
%s
+ %s
- +
- `, fromEscaped, subjectEscaped, bodyEscaped, style, fromEscaped, subjectEscaped, bodyEscaped, app.TimeAgo(msg.Sent), msg.ID) + `, fromEscaped, subjectEscaped, bodyEscaped, style, fromEscaped, subjectEscaped, bodyEscaped, app.TimeAgo(msg.Sent), deleteInfo, msg.ID) } content += `
` } From f9e981186c6f3e8336e594d16d67b4c57d0b11f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 13:49:59 +0000 Subject: [PATCH 08/10] Add reply functionality, improve mail styling, and implement 24-hour auto-delete Co-authored-by: asim <17530+asim@users.noreply.github.com> --- app/html/mu.css | 107 ++++++++++++++++++++++++++++++++++++++++++++++++ mail/mail.go | 93 ++++++++++++++++++++++++++++++++++------- 2 files changed, 186 insertions(+), 14 deletions(-) diff --git a/app/html/mu.css b/app/html/mu.css index d21a76c..5be8e7c 100644 --- a/app/html/mu.css +++ b/app/html/mu.css @@ -347,6 +347,103 @@ td { display: block; margin-bottom: 10px; } +/* Mail Styling */ +.mail-item { + border: 1px solid #ddd; + border-radius: 5px; + padding: 15px; + margin-bottom: 15px; + background: white; + max-width: 100%; +} +.mail-item.unread { + border-left: 4px solid #4CAF50; + background: #f9fff9; +} +.mail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} +.mail-from { + font-size: 0.9em; + color: #666; + font-weight: 500; +} +.mail-time { + font-size: 0.8em; + color: #999; +} +.mail-subject { + font-size: 1.1em; + font-weight: bold; + margin-bottom: 10px; + color: #333; +} +.mail-body { + font-size: 0.95em; + margin-bottom: 12px; + white-space: pre-wrap; + color: #444; + line-height: 1.5; +} +.mail-delete-warning { + font-size: 0.75em; + color: #ff9800; + margin: 8px 0; + padding: 6px 10px; + background: #fff3e0; + border-radius: 3px; + border-left: 3px solid #ff9800; +} +.mail-age-warning { + font-size: 0.75em; + color: #f44336; + margin: 8px 0; + padding: 6px 10px; + background: #ffebee; + border-radius: 3px; + border-left: 3px solid #f44336; +} +.mail-actions { + margin-top: 12px; + display: flex; + gap: 10px; +} +.mail-btn { + padding: 8px 16px; + border-radius: 5px; + font-size: 0.9em; + text-decoration: none; + cursor: pointer; + border: none; + display: inline-block; + text-align: center; +} +.mail-btn-primary { + background: #4CAF50; + color: white; +} +.mail-btn-primary:hover { + background: #45a049; +} +.mail-btn-secondary { + background: #2196F3; + color: white; +} +.mail-btn-secondary:hover { + background: #0b7dda; +} +#mailSearch { + width: 100%; + max-width: 800px; + padding: 10px; + border-radius: 5px; + border: 1px solid darkgrey; + margin-bottom: 20px; + box-sizing: border-box; +} @media only screen and (max-width: 600px) { #topics { text-align: center; @@ -394,6 +491,16 @@ td { min-width: calc(100vw - 85px); max-width: calc(100vw - 85px); } + .mail-item { + width: 100%; + max-width: 100%; + } + .mail-actions { + flex-direction: column; + } + .mail-btn { + width: 100%; + } #home { display: block; flex-direction: column; diff --git a/mail/mail.go b/mail/mail.go index aad66b1..37d2f31 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -144,6 +144,43 @@ func decryptBody(encrypted string) (string, error) { // Load initializes the mail system func Load() { fmt.Println("Mail system loaded") + + // Start background task to auto-delete old unread messages + go autoDeleteOldMessages() +} + +// autoDeleteOldMessages runs periodically to delete unread messages older than 24 hours +func autoDeleteOldMessages() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + mutex.Lock() + + now := time.Now() + deletedCount := 0 + + for username, inbox := range messages { + filtered := []*Message{} + for _, msg := range inbox { + // Delete if unread and older than 24 hours + if !msg.Read && now.Sub(msg.Sent) > 24*time.Hour { + deletedCount++ + fmt.Printf("Auto-deleted unread message %s (sent to %s) after 24 hours\n", msg.ID, username) + } else { + filtered = append(filtered, msg) + } + } + messages[username] = filtered + } + + if deletedCount > 0 { + data.SaveJSON("messages.json", messages) + fmt.Printf("Auto-deleted %d unread messages older than 24 hours\n", deletedCount) + } + + mutex.Unlock() + } } // SendMessage sends a message from one user to another @@ -337,7 +374,23 @@ func Handler(w http.ResponseWriter, r *http.Request) { // GET - show inbox or compose form if r.URL.Query().Get("compose") == "true" { - html := app.RenderHTML("Compose Mail", "Send a message", ComposeTemplate) + // Check if this is a reply + replyTo := r.URL.Query().Get("reply_to") + subject := r.URL.Query().Get("subject") + + composeHTML := `
+

Compose Mail

+
+
+
+
+ +
+
+ Back to Inbox +
` + + html := app.RenderHTML("Compose Mail", "Send a message", composeHTML) w.Write([]byte(html)) return } @@ -386,7 +439,7 @@ func Handler(w http.ResponseWriter, r *http.Request) { // Add search box for client-side filtering content += `
- +
` if len(inbox) == 0 { @@ -394,9 +447,9 @@ func Handler(w http.ResponseWriter, r *http.Request) { } else { content += `
` for _, msg := range inbox { - style := "" + readClass := "" if !msg.Read { - style = "font-weight: bold;" + readClass = "unread" } // Decrypt the message body @@ -410,25 +463,37 @@ func Handler(w http.ResponseWriter, r *http.Request) { // Info text about auto-deletion deleteInfo := "" if msg.DeleteOnRead { - deleteInfo = `
⚠️ This message will be deleted from the server when marked as read
` + deleteInfo = `
⚠️ This message will be deleted from the server when marked as read
` + } + + // Age-based warning for unread messages + age := time.Since(msg.Sent) + ageWarning := "" + if !msg.Read && age > 20*time.Hour { + hoursLeft := 24 - int(age.Hours()) + ageWarning = fmt.Sprintf(`
⏰ This message will auto-delete in ~%d hours if not read
`, hoursLeft) } content += fmt.Sprintf(` -
-
-
From: %s
-
%s
-
%s
-
%s
- %s +
+
+ From: %s + %s +
+
%s
+
%s
+ %s + %s +
+ Reply
- +
- `, fromEscaped, subjectEscaped, bodyEscaped, style, fromEscaped, subjectEscaped, bodyEscaped, app.TimeAgo(msg.Sent), deleteInfo, msg.ID) + `, readClass, fromEscaped, subjectEscaped, bodyEscaped, fromEscaped, app.TimeAgo(msg.Sent), subjectEscaped, bodyEscaped, deleteInfo, ageWarning, fromEscaped, stdhtml.EscapeString(msg.Subject), msg.ID) } content += `
` } From 771bf57e3137eecc0711c4a2d5649345ed840b2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:53:48 +0000 Subject: [PATCH 09/10] Redesign mail UI with Gmail-inspired layout - clean, streamlined, mobile-responsive Co-authored-by: asim <17530+asim@users.noreply.github.com> --- app/html/mu.css | 253 +++++++++++++++++++++++++++++++++--------------- mail/mail.go | 80 +++++++-------- 2 files changed, 212 insertions(+), 121 deletions(-) diff --git a/app/html/mu.css b/app/html/mu.css index 5be8e7c..2f402fc 100644 --- a/app/html/mu.css +++ b/app/html/mu.css @@ -347,103 +347,204 @@ td { display: block; margin-bottom: 10px; } -/* Mail Styling */ -.mail-item { - border: 1px solid #ddd; - border-radius: 5px; - padding: 15px; - margin-bottom: 15px; - background: white; +/* Mail Styling - Gmail-inspired */ +#mail { max-width: 100%; + margin: 0 auto; } -.mail-item.unread { - border-left: 4px solid #4CAF50; - background: #f9fff9; +#mail h1 { + font-size: 1.5em; + margin: 0 0 20px 0; + font-weight: normal; +} +.mail-compose-btn { + display: inline-block; + background: #1a73e8; + color: white; + padding: 12px 24px; + border-radius: 24px; + text-decoration: none; + font-weight: 500; + margin-bottom: 20px; + border: none; + cursor: pointer; +} +.mail-compose-btn:hover { + background: #1557b0; +} +.mail-search { + width: 100%; + max-width: 700px; + padding: 12px 16px; + border: 1px solid #ddd; + border-radius: 8px; + margin-bottom: 20px; + font-size: 14px; + box-sizing: border-box; +} +.mail-list { + max-width: 700px; + border: 1px solid #ddd; + border-radius: 8px; + background: white; + overflow: hidden; } -.mail-header { +.mail-item { + border-bottom: 1px solid #f0f0f0; + padding: 12px 16px; + cursor: pointer; + transition: background 0.1s; display: flex; - justify-content: space-between; align-items: center; - margin-bottom: 8px; + gap: 16px; } -.mail-from { - font-size: 0.9em; - color: #666; - font-weight: 500; +.mail-item:last-child { + border-bottom: none; } -.mail-time { - font-size: 0.8em; - color: #999; +.mail-item:hover { + background: #f5f5f5; } -.mail-subject { - font-size: 1.1em; - font-weight: bold; - margin-bottom: 10px; - color: #333; +.mail-item.unread { + background: #f9f9f9; + font-weight: 600; } -.mail-body { - font-size: 0.95em; - margin-bottom: 12px; - white-space: pre-wrap; - color: #444; - line-height: 1.5; -} -.mail-delete-warning { - font-size: 0.75em; - color: #ff9800; - margin: 8px 0; - padding: 6px 10px; - background: #fff3e0; - border-radius: 3px; - border-left: 3px solid #ff9800; -} -.mail-age-warning { - font-size: 0.75em; - color: #f44336; - margin: 8px 0; - padding: 6px 10px; - background: #ffebee; - border-radius: 3px; - border-left: 3px solid #f44336; +.mail-item-select { + flex-shrink: 0; +} +.mail-item-content { + flex: 1; + min-width: 0; +} +.mail-item-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 4px; + gap: 12px; +} +.mail-item-from { + font-size: 14px; + color: #202124; + font-weight: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; + min-width: 100px; +} +.mail-item-time { + font-size: 12px; + color: #5f6368; + white-space: nowrap; + flex-shrink: 0; +} +.mail-item-subject { + font-size: 14px; + color: #202124; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: inherit; +} +.mail-item-preview { + font-size: 13px; + color: #5f6368; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mail-item-actions { + display: none; + gap: 8px; + margin-top: 8px; } -.mail-actions { - margin-top: 12px; +.mail-item:hover .mail-item-actions { display: flex; - gap: 10px; } .mail-btn { - padding: 8px 16px; - border-radius: 5px; - font-size: 0.9em; + padding: 6px 12px; + border-radius: 4px; + font-size: 13px; text-decoration: none; cursor: pointer; - border: none; + border: 1px solid #dadce0; + background: white; + color: #3c4043; display: inline-block; text-align: center; } -.mail-btn-primary { - background: #4CAF50; +.mail-btn:hover { + background: #f8f9fa; +} +.mail-btn-delete { + background: #d93025; color: white; + border-color: #d93025; } -.mail-btn-primary:hover { - background: #45a049; +.mail-btn-delete:hover { + background: #c5221f; } -.mail-btn-secondary { - background: #2196F3; - color: white; +.mail-empty { + text-align: center; + padding: 40px 20px; + color: #5f6368; + font-size: 14px; } -.mail-btn-secondary:hover { - background: #0b7dda; +/* Compose Form */ +.mail-compose { + max-width: 700px; + background: white; + border: 1px solid #ddd; + border-radius: 8px; + padding: 20px; } -#mailSearch { - width: 100%; - max-width: 800px; - padding: 10px; - border-radius: 5px; - border: 1px solid darkgrey; +.mail-compose h1 { + font-size: 1.3em; margin-bottom: 20px; +} +.mail-compose input[type="text"], +.mail-compose textarea { + width: 100%; + padding: 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + font-family: inherit; + margin-bottom: 12px; box-sizing: border-box; } +.mail-compose textarea { + min-height: 200px; + resize: vertical; +} +.mail-compose button { + background: #1a73e8; + color: white; + padding: 10px 24px; + border-radius: 4px; + border: none; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} +.mail-compose button:hover { + background: #1557b0; +} +.mail-compose-back { + display: inline-block; + margin-top: 16px; + color: #1a73e8; + text-decoration: none; + font-size: 14px; +} +.mail-compose-back:hover { + text-decoration: underline; +} +@media only screen and (max-width: 600px) { +.mail-compose-back:hover { + text-decoration: underline; +} @media only screen and (max-width: 600px) { #topics { text-align: center; @@ -484,21 +585,21 @@ td { .post-show { margin-top: 10px; } - .card, #reminder, #video, #mail { + .card, #reminder, #video { float: none; display: block; width: calc(100vw - 85px); min-width: calc(100vw - 85px); max-width: calc(100vw - 85px); } - .mail-item { - width: 100%; + .mail-list, .mail-compose, .mail-search { max-width: 100%; } - .mail-actions { + .mail-item { flex-direction: column; + align-items: flex-start; } - .mail-btn { + .mail-item-header { width: 100%; } #home { diff --git a/mail/mail.go b/mail/mail.go index 37d2f31..5cffb46 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -379,15 +379,16 @@ func Handler(w http.ResponseWriter, r *http.Request) { subject := r.URL.Query().Get("subject") composeHTML := `
-

Compose Mail

-
-
-
-
- -
-
- Back to Inbox +
+

New Message

+
+ + + + +
+ ← Back to Inbox +
` html := app.RenderHTML("Compose Mail", "Send a message", composeHTML) @@ -433,19 +434,16 @@ func Handler(w http.ResponseWriter, r *http.Request) { var content string content += `
` - content += `

Mail

` - content += fmt.Sprintf(`Compose

`) + content += `

Inbox

` + content += `✏️ Compose` // Add search box for client-side filtering - content += ` -
- -
` + content += `` if len(inbox) == 0 { - content += `

No messages in your inbox.

` + content += `
No messages in your inbox.
` } else { - content += `
` + content += `
` for _, msg := range inbox { readClass := "" if !msg.Read { @@ -460,40 +458,32 @@ func Handler(w http.ResponseWriter, r *http.Request) { subjectEscaped := stdhtml.EscapeString(msg.Subject) bodyEscaped := stdhtml.EscapeString(decryptedBody) - // Info text about auto-deletion - deleteInfo := "" - if msg.DeleteOnRead { - deleteInfo = `
⚠️ This message will be deleted from the server when marked as read
` - } - - // Age-based warning for unread messages - age := time.Since(msg.Sent) - ageWarning := "" - if !msg.Read && age > 20*time.Hour { - hoursLeft := 24 - int(age.Hours()) - ageWarning = fmt.Sprintf(`
⏰ This message will auto-delete in ~%d hours if not read
`, hoursLeft) + // Truncate body for preview + preview := bodyEscaped + if len(preview) > 100 { + preview = preview[:100] + "..." } content += fmt.Sprintf(`
-
- From: %s - %s -
-
%s
-
%s
- %s - %s -
- Reply -
- - - -
+
+
+ %s + %s +
+
%s
+
%s
+
+ Reply +
+ + + +
+
- `, readClass, fromEscaped, subjectEscaped, bodyEscaped, fromEscaped, app.TimeAgo(msg.Sent), subjectEscaped, bodyEscaped, deleteInfo, ageWarning, fromEscaped, stdhtml.EscapeString(msg.Subject), msg.ID) + `, readClass, fromEscaped, subjectEscaped, bodyEscaped, fromEscaped, app.TimeAgo(msg.Sent), subjectEscaped, preview, fromEscaped, stdhtml.EscapeString("Re: "+msg.Subject), msg.ID) } content += `
` } From bc75fc7a2b786337fb11197ab7d4931213dc3bc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:26:39 +0000 Subject: [PATCH 10/10] Fix home grid layout and redesign mail UI with DM-style flat rows and delete icons Co-authored-by: asim <17530+asim@users.noreply.github.com> --- app/html/mu.css | 110 ++++++++++++++++++++++-------------------------- home/home.go | 2 +- mail/mail.go | 43 +++++++++++++------ 3 files changed, 81 insertions(+), 74 deletions(-) diff --git a/app/html/mu.css b/app/html/mu.css index 2f402fc..31b48f7 100644 --- a/app/html/mu.css +++ b/app/html/mu.css @@ -347,7 +347,7 @@ td { display: block; margin-bottom: 10px; } -/* Mail Styling - Gmail-inspired */ +/* Mail Styling - DM-inspired flat design */ #mail { max-width: 100%; margin: 0 auto; @@ -361,10 +361,11 @@ td { display: inline-block; background: #1a73e8; color: white; - padding: 12px 24px; - border-radius: 24px; + padding: 10px 20px; + border-radius: 4px; text-decoration: none; font-weight: 500; + font-size: 14px; margin-bottom: 20px; border: none; cursor: pointer; @@ -375,62 +376,57 @@ td { .mail-search { width: 100%; max-width: 700px; - padding: 12px 16px; + padding: 10px 14px; border: 1px solid #ddd; - border-radius: 8px; + border-radius: 4px; margin-bottom: 20px; font-size: 14px; box-sizing: border-box; } .mail-list { max-width: 700px; - border: 1px solid #ddd; - border-radius: 8px; + border: 1px solid #e0e0e0; + border-radius: 4px; background: white; overflow: hidden; } .mail-item { border-bottom: 1px solid #f0f0f0; - padding: 12px 16px; - cursor: pointer; - transition: background 0.1s; + padding: 14px 16px; display: flex; align-items: center; - gap: 16px; + gap: 12px; + background: white; } .mail-item:last-child { border-bottom: none; } -.mail-item:hover { - background: #f5f5f5; -} .mail-item.unread { - background: #f9f9f9; - font-weight: 600; -} -.mail-item-select { - flex-shrink: 0; + background: #f8f9fa; + border-left: 3px solid #1a73e8; + padding-left: 13px; } .mail-item-content { flex: 1; min-width: 0; + cursor: pointer; } .mail-item-header { display: flex; justify-content: space-between; align-items: baseline; - margin-bottom: 4px; - gap: 12px; + margin-bottom: 3px; + gap: 10px; } .mail-item-from { font-size: 14px; color: #202124; - font-weight: inherit; + font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + min-width: 80px; flex-shrink: 1; - min-width: 100px; } .mail-item-time { font-size: 12px; @@ -439,13 +435,12 @@ td { flex-shrink: 0; } .mail-item-subject { - font-size: 14px; + font-size: 13px; color: #202124; margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-weight: inherit; } .mail-item-preview { font-size: 13px; @@ -454,36 +449,20 @@ td { overflow: hidden; text-overflow: ellipsis; } -.mail-item-actions { - display: none; - gap: 8px; - margin-top: 8px; -} -.mail-item:hover .mail-item-actions { - display: flex; -} -.mail-btn { - padding: 6px 12px; - border-radius: 4px; - font-size: 13px; - text-decoration: none; +.mail-item-delete { + flex-shrink: 0; + background: none; + border: none; cursor: pointer; - border: 1px solid #dadce0; - background: white; - color: #3c4043; - display: inline-block; - text-align: center; -} -.mail-btn:hover { - background: #f8f9fa; -} -.mail-btn-delete { - background: #d93025; - color: white; - border-color: #d93025; + padding: 6px; + color: #5f6368; + font-size: 18px; + line-height: 1; + opacity: 0.6; } -.mail-btn-delete:hover { - background: #c5221f; +.mail-item-delete:hover { + opacity: 1; + color: #d93025; } .mail-empty { text-align: center; @@ -495,18 +474,31 @@ td { .mail-compose { max-width: 700px; background: white; - border: 1px solid #ddd; - border-radius: 8px; + border: 1px solid #e0e0e0; + border-radius: 4px; padding: 20px; } .mail-compose h1 { font-size: 1.3em; margin-bottom: 20px; } +.mail-compose-original { + background: #f8f9fa; + border-left: 3px solid #e0e0e0; + padding: 12px; + margin-bottom: 16px; + font-size: 13px; + color: #5f6368; +} +.mail-compose-original-label { + font-weight: 500; + color: #202124; + margin-bottom: 6px; +} .mail-compose input[type="text"], .mail-compose textarea { width: 100%; - padding: 12px; + padding: 10px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; @@ -515,13 +507,13 @@ td { box-sizing: border-box; } .mail-compose textarea { - min-height: 200px; + min-height: 150px; resize: vertical; } .mail-compose button { background: #1a73e8; color: white; - padding: 10px 24px; + padding: 8px 20px; border-radius: 4px; border: none; font-size: 14px; @@ -533,7 +525,7 @@ td { } .mail-compose-back { display: inline-block; - margin-top: 16px; + margin-left: 12px; color: #1a73e8; text-decoration: none; font-size: 14px; diff --git a/home/home.go b/home/home.go index 3e30252..195f91d 100644 --- a/home/home.go +++ b/home/home.go @@ -20,8 +20,8 @@ func Cards(mailStatus, news, markets, reminder, latest string) []string { latest += app.Link("More", "/video") cards := []string{ - app.Card("mail", "Mail", mailStatus), app.Card("news", "News", news), + app.Card("mail", "Mail", mailStatus), app.Card("reminder", "Reminder", reminder), app.Card("markets", "Markets", markets), app.Card("video", "Video", latest), diff --git a/mail/mail.go b/mail/mail.go index 5cffb46..5b96367 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -377,17 +377,29 @@ func Handler(w http.ResponseWriter, r *http.Request) { // Check if this is a reply replyTo := r.URL.Query().Get("reply_to") subject := r.URL.Query().Get("subject") + originalBody := r.URL.Query().Get("original") composeHTML := `
-

New Message

+

New Message

` + + // Show original message if it's a reply + if originalBody != "" { + composeHTML += ` +
+
Original message:
+ ` + stdhtml.EscapeString(originalBody) + ` +
` + } + + composeHTML += `
+ Cancel
- ← Back to Inbox
` @@ -460,30 +472,33 @@ func Handler(w http.ResponseWriter, r *http.Request) { // Truncate body for preview preview := bodyEscaped - if len(preview) > 100 { - preview = preview[:100] + "..." + if len(preview) > 80 { + preview = preview[:80] + "..." } + // URL encode the original body for reply + replyURL := fmt.Sprintf("/mail?compose=true&reply_to=%s&subject=%s&original=%s", + fromEscaped, + stdhtml.EscapeString("Re: "+msg.Subject), + stdhtml.EscapeString(decryptedBody)) + content += fmt.Sprintf(`
-
+
%s %s
%s
%s
-
- Reply -
- - - -
-
+
+ + + +
- `, readClass, fromEscaped, subjectEscaped, bodyEscaped, fromEscaped, app.TimeAgo(msg.Sent), subjectEscaped, preview, fromEscaped, stdhtml.EscapeString("Re: "+msg.Subject), msg.ID) + `, readClass, fromEscaped, subjectEscaped, bodyEscaped, replyURL, fromEscaped, app.TimeAgo(msg.Sent), subjectEscaped, preview, msg.ID) } content += `
` }