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 = `
+
+`
+
+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
- `, 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 := `