git.sophuwu.com > mailboxxer
major changes, too much for me to write
sophuwu sophie@sophuwu.com
Sun, 03 Aug 2025 23:57:03 +0200
commit

96227b0f898ab5a97832879df860613138dfb8ea

parent

dc5fca28bf116c83e3ebd1513b5318f2953a8170

8 files changed, 922 insertions(+), 466 deletions(-)

jump to
M cli.gocli.go

@@ -2,6 +2,7 @@ package main

import ( "bytes" + "errors" "fmt" "golang.org/x/crypto/ssh/terminal" "golang.org/x/net/html"

@@ -11,8 +12,8 @@ "os"

"os/exec" "path/filepath" "slices" + "sophuwu.site/mailboxxer/db" "strings" - "time" ) func GetTSize() (int, int) {

@@ -23,44 +24,6 @@ }

return w, h } -func TimeStr(s string) string { - t, err := time.Parse(TimeFormat, s) - if err != nil { - return s - } - n := time.Now().Local() - return strings.ReplaceAll(func() string { - if t.Year() != n.Year() { - return t.Format("Jan 02th 2006") - } - d := time.Since(t) - if d.Hours() > 24*6 { - return t.Format("Jan 02th") - } - if d.Hours() > 24 { - return t.Format("Mon 15:04") - } - if d.Hours() > 1 { - return fmt.Sprintf("%d h ago", int(d.Hours())) - } - return fmt.Sprintf("%d m ago", int(d.Minutes())) - }(), "th", (func(day int) string { - if day/10 == 1 { - return "th" - } - switch day % 10 { - case 1: - return "st" - case 2: - return "nd" - case 3: - return "rd" - default: - return "th" - } - })(t.Day())) -} - func displayAddress(a string) string { addr, err := mail.ParseAddress(a) if err != nil {

@@ -115,19 +78,11 @@ node(&buff, doc)

return buff.String() } -func (em EmailMeta) Display() string { - s := fmt.Sprintf("%13.13s | ", TimeStr(em.Date)) - s += fmt.Sprintf("%40.40s | ", displayAddress(em.From)) - s += fmt.Sprintf("%s", em.Subject) - return s -} - -func DisplayRows(metas []EmailMeta, page int, h int) { - fmt.Println("Page: ", page) - fmt.Printf("id:\t%13.13s | %40.40s | %s\n", "Date", "From", "Subject") - page *= h - for i := page; i < len(metas) && i < page+h; i++ { - fmt.Printf("%d:\t%s\n", i-page, metas[i].Display()) +func DisplayRows(q *db.Query) { + fmt.Printf("Page: %d/%d\n", q.Page(), q.TotalPages()) + fmt.Printf(" id | %13.13s | %40.40s | %s\n", "Date", "From", "Subject") + for i, em := range q.Rows() { + fmt.Printf("%3d | %13.13s | %40.40s | %s\n", i, db.TimeStr(em.Date), displayAddress(em.From), em.Subject) } }

@@ -158,81 +113,120 @@ lines = slices.Clip(lines)

return " " + strings.Join(lines, " \n ") } -func OpenMail(metas []EmailMeta, page int, h int, s string, w int) error { - n := 0 - for _, r := range s { - if r < '0' || r > '9' { - break - } - n = n*10 + int(r-'0') - } - if !strings.HasPrefix(s, fmt.Sprintf("%d", n)) { - return fmt.Errorf("invalid number") +var ErrInvalidNumber = errors.New("invalid number") + +func OpenMail(r *db.Query, s string) error { + n, err := parseInt(s) + if err != nil { + return ErrInvalidNumber } - n += page * h - if n >= len(metas) || n < 0 { - return fmt.Errorf("invalid number") + var meta db.EmailMeta + meta, err = r.Row(n) + if err != nil { + return ErrInvalidNumber } - id := metas[n].Id + id := meta.Id var b []byte - path, err := filepath.Glob(filepath.Join(SAVEPATH, id, "body.*")) - if len(path) == 0 { - return fmt.Errorf("no email found") + isHtml := false + b, err = os.ReadFile(filepath.Join(db.SAVEPATH, id, "body.txt")) + if errors.Is(err, os.ErrNotExist) { + b, err = os.ReadFile(filepath.Join(db.SAVEPATH, id, "body.html")) + isHtml = true } - b, err = os.ReadFile(path[0]) if err != nil { return err } s = string(b) - if filepath.Ext(path[0]) == ".html" { + if isHtml { s = RenderHTML(s) } - w -= 4 - s = wrap(w, s) - cmd := exec.Command("less", "-sR") cmd.Stdin = strings.NewReader(s) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - return cmd.Run() + fmt.Print("\033[?1049l") + err = cmd.Run() + fmt.Print("\033[?1049h") + return err } -func CLI(metas *[]EmailMeta) { - // use alternate screen - fmt.Print("\033[?1049h") - defer fmt.Print("\033[?1049l") - page := 0 - var s string +func getSize(W, H *int) bool { w, h := GetTSize() - h -= 5 - if h > 20 { - h = 20 + if h > 28 { + h = 28 } else if h < 5 { h = 5 } + h -= 4 + b := false + if H != nil { + if *H != h { + b = true + } + *H = h + } + if W != nil { + *W = w + } + return b +} + +func parseInt(s string) (int, error) { + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + return 0, fmt.Errorf("invalid number: %s", s) + } + n = n*10 + int(r-'0') + } + return n, nil +} + +func CLI() { + var s string + var w, h int + getSize(&w, &h) + r, err := db.NewQuery(h) + if err != nil { + fmt.Fprintln(os.Stderr, "Error creating query:", err) + return + } + + // alternative screen + fmt.Print("\033[?1049h") + // on exit restore terminal + defer fmt.Print("\033[?1049l") + for { - // clear screen and move cursor to top fmt.Print("\033[2J\033[H\r") - if page < 0 { - page = 0 + if getSize(&w, &h) { + if err = r.SetPageSize(h); err != nil { + break + } } - DisplayRows(*metas, page, h) - fmt.Println("n: next, p: previous, q: quit, (id): open") - fmt.Print("(n/p/q/id): ") + DisplayRows(r) + fmt.Println("n: next page, p: previous | q: quit | <id>: open") + fmt.Print("input (n/p/q/id): ") + s = "" fmt.Scanln(&s) switch s { - case "n": - page++ - case "p": - page-- - case "q": - return case "": continue - default: // open mail - if err := OpenMail(*metas, page, h, s, w); err != nil { - fmt.Println(err) + case "q": + return + case "n": + err = r.Next() + case "p": + err = r.Prev() + default: + if err = OpenMail(r, s); errors.Is(err, ErrInvalidNumber) { + continue } } + if err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + return + } + } }
A db/db.go

@@ -0,0 +1,226 @@

+package db + +import ( + "database/sql" + "fmt" + _ "github.com/glebarez/go-sqlite" + "os" + "path/filepath" +) + +var DBPATH, INBOX, SAVEPATH string + +func getHomeBox() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return filepath.Join(home, ".mailbox") +} + +func getConf() { + var mailbox string + if len(os.Args) > 2 && os.Args[1] == "-m" { + mailbox = os.Args[2] + } else { + mailbox = getHomeBox() + } + var err error + if _, err = os.Stat(mailbox); os.IsNotExist(err) { + os.MkdirAll(mailbox, 0700) + } + DBPATH = filepath.Join(mailbox, "mailbox.sqlite") + INBOX = filepath.Join(mailbox, "inbox", "new") + if _, err = os.Stat(INBOX); os.IsNotExist(err) { + os.MkdirAll(INBOX, 0700) + } + SAVEPATH = filepath.Join(mailbox, "saved") + if _, err = os.Stat(SAVEPATH); os.IsNotExist(err) { + os.MkdirAll(SAVEPATH, 0700) + } +} + +func readRows(rows *sql.Rows) ([]EmailMeta, error) { + var metas []EmailMeta + var meta EmailMeta + var err error + for rows.Next() { + err = rows.Scan(&meta.Id, &meta.Subject, &meta.To, &meta.From, &meta.Date) + if err != nil { + return metas, err + } + metas = append(metas, meta) + } + return metas, nil +} + +var db *sql.DB + +func openDB() error { + var err error + db, err = sql.Open("sqlite", DBPATH) + if err != nil { + return err + } + if db == nil { + return fmt.Errorf("unknown reason") + } + _, err = db.Exec("CREATE TABLE IF NOT EXISTS emails (id TEXT PRIMARY KEY, subject TEXT, toaddr TEXT, fromaddr TEXT, date TEXT)") + if err != nil { + return err + } + return nil +} + +func Open() { + getConf() + err := openDB() + if err != nil { + fmt.Fprintln(os.Stderr, "error opening database: ", err) + os.Exit(1) + } + err = parseNewMail() + if err != nil { + fmt.Fprintln(os.Stderr, "error parsing new mail: ", err) + os.Exit(1) + return + } +} + +func Close() { + if db != nil { + if err := db.Close(); err != nil { + fmt.Fprintln(os.Stderr, "error closing database: ", err) + } + db = nil + } +} + +type Query struct { + rows []EmailMeta + page int + pageSize int + totalRows int + totalPages int + where string +} + +func (r *Query) Page() int { + return r.page + 1 +} +func (r *Query) PageSize() int { + return r.pageSize +} +func (r *Query) TotalRows() int { + return r.totalRows +} +func (r *Query) TotalPages() int { + return r.totalPages + 1 +} +func (r *Query) Rows() []EmailMeta { + return r.rows +} +func (r *Query) Row(i int) (EmailMeta, error) { + if i < 0 || i >= len(r.rows) { + return EmailMeta{}, fmt.Errorf("index out of range: %d", i) + } + return r.rows[i], nil +} +func (r *Query) whereClause(q *string) { + if r.where != "" { + *q += " WHERE " + r.where + } +} +func (r *Query) executeQuery() error { + if db == nil { + return fmt.Errorf("database not opened") + } + q := `select count(id) from emails` + r.whereClause(&q) + row := db.QueryRow(q) + if row == nil { + return fmt.Errorf("query error: no rows returned") + } + if err := row.Scan(&r.totalRows); err != nil { + return fmt.Errorf("query error: %w", err) + } + if r.pageSize <= 0 { + r.pageSize = 5 + } else if r.pageSize > 100 { + r.pageSize = 100 + } + if r.totalRows == 0 { + r.rows = []EmailMeta{} + r.page = 0 + r.totalPages = 0 + return nil + } + r.totalPages = (r.totalRows - 1) / r.pageSize + if r.page < 0 { + r.page = 0 + } else if r.page > r.totalPages { + r.page = r.totalPages + } + q = `SELECT * FROM emails` + r.whereClause(&q) + q += fmt.Sprintf(" ORDER BY date DESC LIMIT %d OFFSET %d", r.pageSize, r.page*r.pageSize) + rows, err := db.Query(q) + if err != nil { + return fmt.Errorf("query error: %w", err) + } + r.rows, err = readRows(rows) + if err != nil { + return fmt.Errorf("error reading rows: %w", err) + } + return nil +} + +func (r *Query) Next() error { + if r.page >= r.totalPages { + return nil + } + r.page++ + return r.executeQuery() +} +func (r *Query) Prev() error { + if r.page <= 0 { + return nil + } + r.page-- + return r.executeQuery() +} +func (r *Query) SetWhere(where string) error { + r.where = where + r.page = 0 + return r.executeQuery() +} +func (r *Query) GetWhere() string { + return r.where +} +func (r *Query) SetPage(page int) error { + r.page = page + return r.executeQuery() +} +func (r *Query) SetPageSize(size int) error { + if size <= 0 { + return fmt.Errorf("page size must be greater than zero") + } + if size > 100 { + size = 100 + } + r.pageSize = size + return r.executeQuery() +} + +func NewQuery(pageSize int) (*Query, error) { + r := &Query{ + page: 0, + pageSize: pageSize, + where: "", + } + if err := r.executeQuery(); err != nil { + return nil, fmt.Errorf("error executing query: %w", err) + } + return r, nil +}
M files.godb/files.go

@@ -1,4 +1,4 @@

-package main +package db import ( "bytes"

@@ -73,7 +73,7 @@ }

var cidheader = regexp.MustCompile(`^cidname: [^ ]+ [^ ]+$`) // Content-ID header -func EmlFiles(eml *mail.Message, head []byte) FileList { +func emlFiles(eml *mail.Message, head []byte) FileList { files := make(FileList) getfiles(&files, eml)

@@ -87,12 +87,12 @@ files["header.txt"] = head

return files } -func GetFiles(b *bytes.Buffer) (FileList, error) { +func getFiles(b *bytes.Buffer) (FileList, error) { head := bytes.SplitN(b.Bytes(), []byte{10, 10}, 2)[0] head = bytes.ReplaceAll(head, []byte{'\t'}, []byte{' '}) e, err := mail.ReadMessage(b) if err != nil { return nil, err } - return EmlFiles(e, head), nil + return emlFiles(e, head), nil }
D http.go

@@ -1,248 +0,0 @@

-package main - -import ( - "database/sql" - "fmt" - "html" - "net/http" - "net/mail" - "os" - "path/filepath" - "strings" -) - -type ReturnQuery struct { - EM []EmailMeta - Err error -} - -type Query struct { - Query string - Return chan ReturnQuery -} - -func (q *Query) Error(err error) { - if q.Return != nil { - q.Return <- ReturnQuery{ - Err: err, - } - } -} -func (q *Query) Result(m []EmailMeta, err error) { - if q.Return != nil { - q.Return <- ReturnQuery{ - EM: m, - Err: err, - } - } -} - -var QChan = make(chan *Query, 10) - -func Web(db *sql.DB) { - go func() { - err := http.ListenAndServe("127.0.1.69:3141", http.HandlerFunc(Http)) - if err != nil { - QChan <- &Query{Query: "EXIT"} - } - }() - var r *sql.Rows - var q *Query - var err error - for { - q = <-QChan - if q.Query == "EXIT" { - q.Error(fmt.Errorf("server stopped")) - break - } - if q.Query == "" { - q.Error(fmt.Errorf("empty query")) - continue - } - if r, err = db.Query(q.Query); err != nil { - q.Error(fmt.Errorf("query error: %w", err)) - continue - } - q.Result(ReadRows(r)) - } - close(QChan) -} - -func E(s ...string) []any { - a := make([]any, len(s)) - for i, v := range s { - a[i] = html.EscapeString(v) - } - return a -} - -type HtmlEM struct { - Id string - Date string - Subject string - ToName string - ToAddr string - FromName string - FromAddr string -} - -func Http(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/" { - r.ParseForm() - var q []string - if r.Form.Get("to") != "" { - q = append(q, fmt.Sprintf(` toaddr LIKE '%%%s%%'`, r.Form.Get("to"))) - } - if r.Form.Get("from") != "" { - q = append(q, fmt.Sprintf(` fromaddr LIKE '%%%s%%'`, r.Form.Get("from"))) - } - if r.Form.Get("subject") != "" { - q = append(q, fmt.Sprintf(` subject LIKE '%%%s%%'`, r.Form.Get("subject"))) - } - if r.Form.Get("date") != "" { - q = append(q, fmt.Sprintf(` date LIKE '%%%s%%'`, r.Form.Get("date"))) - } - qu := Query{ - Query: fmt.Sprintf("SELECT * FROM emails %s ORDER BY date DESC", func() string { - if len(q) == 0 { - return "" - } - return "WHERE " + strings.Join(q, " AND ") - }()), - Return: make(chan ReturnQuery), - } - QChan <- &qu - ret := <-qu.Return - close(qu.Return) - if ret.Err != nil { - http.Error(w, "Internal Server Error", 500) - return - } - if len(ret.EM) == 0 { - fmt.Fprint(w, `<html><head><title>No Emails</title></head><body><h1>No Emails Found</h1></body></html>`) - return - } - var htmlMetas []HtmlEM - var err error - var from *mail.Address - var to []*mail.Address - var addrlist []string - var htmlMeta HtmlEM - for _, em := range ret.EM { - htmlMeta = HtmlEM{ - Id: em.Id, - Date: TimeStr(em.Date), - Subject: em.Subject, - } - from, err = mail.ParseAddress(em.From) - if err != nil { - htmlMeta.FromAddr = em.From - } else { - htmlMeta.FromAddr = from.Address - htmlMeta.FromName = from.Name - } - if htmlMeta.FromName == "" { - htmlMeta.FromName = htmlMeta.FromAddr - } - to, err = mail.ParseAddressList(em.To) - if err != nil || len(to) == 0 { - addrlist = strings.Split(em.To, ", ") - if len(addrlist) == 0 { - htmlMeta.ToAddr = em.To - } else { - htmlMeta.ToAddr = addrlist[0] - } - } else { - htmlMeta.ToAddr = to[0].Address - htmlMeta.ToName = to[0].Name - } - if htmlMeta.ToName == "" { - htmlMeta.ToName = htmlMeta.ToAddr - } - htmlMetas = append(htmlMetas, htmlMeta) - } - fmt.Fprint(w, `<html><head><title>Emails</title> -<style> -section { - display: flex; - flex-direction: row; - cursor: pointer; - border-bottom: 1px solid #666; - width: 100%; -} -section > *{ - display: inline-block; - padding: 5px; -} -section > div:last-child { - border-right: none!important; -} -section > div { - border-right: 1px solid #666; -} -section:hover { - background-color: rgba(0,0,0,0.2); -} -.addr { - font-weight: bold; -} -body { - margin: 10px; - display: flex; - flex-direction: column; -} -marquee { - width: 100%; -} -.addr > marquee { - display: none; -} -.addr:hover > marquee { - display: inline; -} -.addr:hover > span { - display: none; -} -.addr > span { - display: inline; -} -div { - margin: 5px auto 5px auto; - overflow: hidden; -} -.time { - width: 13ch; -} -.sub { - width: calc(100% - 13ch - 20%); -} -.addr { - width: 15%; - font-size: 0.8em; -} -</style> -</head><body>`) - for _, em := range htmlMetas { - fmt.Fprintf(w, `<section onclick="location.href='%s/html.html'"> - <div class="time">%s</div> - <div class="addr" title="%s">%s</div> - <div class="addr" title="%s">%s</div> - <div class="sub">%s</div> -</section>`, E(em.Id, em.Date, em.FromAddr, em.FromName, em.ToAddr, em.ToName, em.Subject)...) - } - fmt.Fprintf(w, "</body></html>") - return - } - if filepath.Base(r.URL.Path) == "html.html" && len(strings.Split(r.URL.Path[1:], "/")) == 2 { - id := filepath.Base(filepath.Dir(r.URL.Path)) - s, _ := os.ReadFile(filepath.Join(SAVEPATH, id, "header.txt")) - b, _ := os.ReadFile(filepath.Join(SAVEPATH, id, "body.txt")) - fmt.Fprintf(w, `<html><head><title>%s</title></head> -<body style="display: flex; flex-direction: row;"> -<div style="width: 50%%;display:inline;height:100%%;overflow:scroll;"><pre>%s</pre><br><pre>%s</pre></div> -<iframe style="width: 50%%;display:inline;height:100%%;overflow:scroll;" src="%s"></iframe> -</body></html>`, id, string(s), string(b), "/"+id) - return - } - http.FileServer(http.Dir(SAVEPATH)).ServeHTTP(w, r) -}
M main.gomain.go

@@ -1,109 +1,21 @@

package main import ( - "database/sql" - "fmt" - _ "github.com/glebarez/go-sqlite" "os" - "path/filepath" + "sophuwu.site/mailboxxer/db" + "sophuwu.site/mailboxxer/web" ) -var DBPATH, INBOX, SAVEPATH string - -func getHomeBox() string { - home, err := os.UserHomeDir() - if err != nil || home == "" { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - return filepath.Join(home, ".mailbox") -} - -func init() { - var mailbox string - if len(os.Args) > 2 && os.Args[1] == "-m" { - mailbox = os.Args[2] - } else { - mailbox = getHomeBox() - } - var err error - if _, err = os.Stat(mailbox); os.IsNotExist(err) { - os.MkdirAll(mailbox, 0700) - } - DBPATH = filepath.Join(mailbox, "mailbox.sqlite") - INBOX = filepath.Join(mailbox, "inbox", "new") - if _, err = os.Stat(INBOX); os.IsNotExist(err) { - os.MkdirAll(INBOX, 0700) - } - SAVEPATH = filepath.Join(mailbox, "saved") - if _, err = os.Stat(SAVEPATH); os.IsNotExist(err) { - os.MkdirAll(SAVEPATH, 0700) - } -} - -func ReadRows(rows *sql.Rows) ([]EmailMeta, error) { - var metas []EmailMeta - var meta EmailMeta - var err error - for rows.Next() { - err = rows.Scan(&meta.Id, &meta.Subject, &meta.To, &meta.From, &meta.Date) - if err != nil { - return metas, err - } - metas = append(metas, meta) - } - return metas, nil -} - func main() { - db, err := sql.Open("sqlite", DBPATH) - if err != nil { - fmt.Fprintln(os.Stderr, err) - return - } - _, err = db.Exec("CREATE TABLE IF NOT EXISTS emails (id TEXT PRIMARY KEY, subject TEXT, toaddr TEXT, fromaddr TEXT, date TEXT)") - if err != nil { - fmt.Fprintln(os.Stderr, err) - return - } - defer func() { - db.Exec("COMMIT") - db.Close() - }() - var metas []EmailMeta - err = ScanDir(&metas) - for _, em := range metas { - _, err = db.Exec("INSERT INTO emails (id, subject, toaddr, fromaddr, date) VALUES (?, ?, ?, ?, ?)", em.Id, em.Subject, em.To, em.From, em.Date) - if err != nil { - fmt.Fprintln(os.Stderr, err) - return - } - } - if err != nil { - fmt.Fprintln(os.Stderr, err) - return - } - rows, err := db.Query("SELECT * FROM emails ORDER BY date DESC") - if err != nil { - fmt.Fprintln(os.Stderr, err) - return - } - metas, err = ReadRows(rows) - if err != nil { - fmt.Fprintln(os.Stderr, err) - return - } + db.Open() + defer db.Close() for _, arg := range os.Args[1:] { if arg == "--cli" { - CLI(&metas) - return - } - if arg == "--" { - stdin() + CLI() return } if arg == "--web" { - Web(db) + web.ServeHttp() return } }
M parse.godb/parse.go

@@ -1,4 +1,4 @@

-package main +package db import ( "bytes"

@@ -9,12 +9,13 @@ "mime"

"net/mail" "os" "path/filepath" + "strings" "time" ) const TimeFormat = "2006-01-02 15:04:05 -0700" -func SaveEmail(em EmailMeta, files FileList) error { +func saveEmail(em EmailMeta, files FileList) error { path := filepath.Join(SAVEPATH, em.Id) var err error _ = os.MkdirAll(path, 0700)

@@ -27,43 +28,48 @@ }

return err } -func NewEntry(f os.DirEntry) (EmailMeta, error) { +func newEntry(f os.DirEntry) error { if !f.Type().IsRegular() { - return EmailMeta{}, fmt.Errorf("unsupported file type in directory") + return fmt.Errorf("unsupported file type in directory") } - meta, files, err := Parse(filepath.Join(INBOX, f.Name())) + meta, files, err := parse(filepath.Join(INBOX, f.Name())) if err != nil { - return meta, err + return fmt.Errorf("error parsing email: %w", err) } if meta.Date == "" { s, _ := f.Info() meta.Date = s.ModTime().Format(TimeFormat) } - err = SaveEmail(meta, files) + _, err = db.Exec("INSERT INTO emails (id, subject, toaddr, fromaddr, date) VALUES (?, ?, ?, ?, ?)", meta.Id, meta.Subject, meta.To, meta.From, meta.Date) + if err != nil { + return fmt.Errorf("error inserting email into database: %w", err) + } + err = saveEmail(meta, files) if err != nil { - return meta, err + return fmt.Errorf("error saving email files: %w", err) } err = os.Remove(filepath.Join(INBOX, f.Name())) - return meta, err + if err != nil { + return fmt.Errorf("error cleaning tmp file: %w", err) + } + return nil } -func ScanDir(newEmails *[]EmailMeta) error { +func parseNewMail() error { dir, err := os.ReadDir(INBOX) if err != nil { return err } - var meta EmailMeta for _, f := range dir { - meta, err = NewEntry(f) + err = newEntry(f) if err != nil { return err } - *newEmails = append(*newEmails, meta) } return nil } -func Parse(path string) (EmailMeta, FileList, error) { +func parse(path string) (EmailMeta, FileList, error) { var email bytes.Buffer var meta EmailMeta var files FileList

@@ -72,11 +78,11 @@ if err != nil {

return meta, files, err } email.Write(b) - meta, err = GenerateMeta(email) + meta, err = generateMeta(email) if err != nil { return meta, files, err } - files, err = GetFiles(&email) + files, err = getFiles(&email) if err != nil { return meta, files, err }

@@ -92,7 +98,7 @@ return ""

} return d.Format(TimeFormat) } -func ShaHash(b []byte) string { +func shaHash(b []byte) string { h := sha1.New() h.Write(b) return fmt.Sprintf("%X", h.Sum(nil))

@@ -112,11 +118,11 @@ }

} }(mime.WordDecoder{}) -// GenerateMeta generates the EmailMeta for the EmailData +// generateMeta generates the EmailMeta for the EmailData // This is used to index the email in the database -func GenerateMeta(email bytes.Buffer) (EmailMeta, error) { +func generateMeta(email bytes.Buffer) (EmailMeta, error) { var em EmailMeta - em.Id = ShaHash(email.Bytes()) + em.Id = shaHash(email.Bytes()) em.Subject = "No Subject" e, err := mail.ReadMessage(bytes.NewReader(email.Bytes()))

@@ -159,6 +165,44 @@

return em, nil } +func TimeStr(s string) string { + t, err := time.Parse(TimeFormat, s) + if err != nil { + return s + } + n := time.Now().Local() + return strings.ReplaceAll(func() string { + if t.Year() != n.Year() { + return t.Format("Jan 02th 2006") + } + d := time.Since(t) + if d.Hours() > 24*6 { + return t.Format("Jan 02th") + } + if d.Hours() > 24 { + return t.Format("Mon 15:04") + } + if d.Hours() > 1 { + return fmt.Sprintf("%d h ago", int(d.Hours())) + } + return fmt.Sprintf("%d m ago", int(d.Minutes())) + }(), "th", (func(day int) string { + if day/10 == 1 { + return "th" + } + switch day % 10 { + case 1: + return "st" + case 2: + return "nd" + case 3: + return "rd" + default: + return "th" + } + })(t.Day())) +} + // EmailMeta contains the fields that will be searchable in the database type EmailMeta struct { From string `json:"From"`

@@ -172,7 +216,7 @@ func stdin() {

var err error var b bytes.Buffer b.ReadFrom(os.Stdin) - fl, e := GetFiles(&b) + fl, e := getFiles(&b) if e != nil { fmt.Fprintln(os.Stderr, e) return
A web/templates/index.html

@@ -0,0 +1,280 @@

+{{ define "index" }} +<!DOCTYPE html> +<html><head><title>Emails</title> +<style> +:root { + @media (max-width: 1100px) { + .recpt { + display: none; + } + --subwidth: 25%; + } +{{ if .DarkMode }} + --dark-light: {{ .DarkMode }}; +{{ end }} + + + --cont: calc(var(--dark-light, 0) * 100%); + --fg-color: rgb(var(--cont),var(--cont),var(--cont)); + + --bg-color: rgb(from var(--fg-color) calc(255 - r) calc(255 - g) calc(255 - b)); + --accent: color-mix(in lab, var(--accent1,#fcaeff), var(--bg-color) 50%); + --border-color: color-mix(in srgb, var(--fg-color), var(--bg-color) 80%); + --accent-light: color-mix(in srgb, var(--accent), var(--bg-color) 70%); +} +html { + color: var(--fg-color); + background-color: var(--bg-color); +} +section { + display: flex; + flex-direction: row; + cursor: pointer; + border-bottom: 1px solid var(--border-color); + width: 100%; +} +section > *{ + display: inline-block; + padding: 5px; +} +section > div:last-child { + border-right: none!important; +} +section > div { + border-right: 1px solid var(--border-color); +} + +main > section:hover{ + background-color: var(--accent-light); +} +.addr { + font-weight: bold; +} +.addr:hover > span { + display: none; +} +.addr > span { + display: inline; +} +div { + margin: 0 auto 0 auto; + overflow: clip; + text-overflow: ellipsis; + text-wrap: nowrap; +} +.time { + width: 13ch; +} +.sub { + width: calc(100% - 13ch - var(--subwidth, 40%)); +} +.addr { + width: 20%; + font-size: 0.8em; +} +.pageform { + display: flex; + align-items: center; + justify-content: space-between; +} +.buttholder { + margin: 1ch 1lh; +} +button { + border: 1px solid var(--border-color); + border-radius: 0.5ch; +} +button:hover { + cursor: pointer; + background-color: var(--accent); +} +body { + margin: 0.5lh 1ch; + display: flex; + flex-direction: column; + position: fixed; + width: calc(100% - 2ch); + height: calc(100% - 1lh); + padding: 0; +} +header { + margin: 0; + padding: 0; +} +.frameholder { + z-index: 4; + position: absolute; + width: calc(100% - 2em); + height: calc(100% - 2em); + display: none; + left: 50%; + top: 50%; + transform: translate(-50%,-50%); + border: 2em solid transparent; + background-color: color-mix(in lab, var(--fg-color), transparent 40%); +} +.header { + background: var(--accent); +} +.frameholder > header { + display: flex; + flex-direction: row; + justify-content: space-between; + height: 3lh; + align-items: center; + padding: 0 3rlh 0 0.5rlh; +} +.frameholder > #iframe { + width: 100%; + height: calc(100% - 3lh); + border: none; + background: var(--bg-color); +} +.frameholder > header > button { + height: 2rlh; + width: 2rlh; + background-color: #b42032; + font-weight: bold; + color: var(--bg-color); + border-radius: 5px; + border: none; + position: absolute; + right: 0.5rlh; + font-size: 1.5rlh; +} +.frameholder > header > button:hover { + background-color: #ef2b30; +} +main { + overflow-y: auto; + width: 100%; + height: 100%; + margin: 0; + display: flex; + flex-direction: column; +} +h1 { + margin: 0; +} + +</style> +<style class="uwustyle"></style> +<script> + var frame; + var iframe; + document.addEventListener("DOMContentLoaded", (function() { + frame = document.getElementById("frame"); + iframe = document.getElementById("iframe"); + frame.querySelector("header > button").addEventListener("click", function() { + iframe.src = ""; + frame.style.display = "none"; + }); + loadDarkMode(); + })); + function pageUpOrDown(e) { + let pf = document.getElementById("pageform"); + let v = parseInt(pf.value); + if (e.innerHTML === "&lt;" || e.innerText === "<") { + pf.value = (v-1).toString(); + } else if (e.innerHTML === "&gt;" || e.innerText === ">") { + pf.value = (v+1).toString(); + } else { + return; + } + pf.form.submit(); + } + function setIframeSrc(elem) { + let id = elem.id; + let to = elem.querySelector(".to-addr").title; + let from = elem.querySelector(".from-addr").title; + let subject = elem.querySelector(".subject").innerText; + let date = elem.querySelector(".date-str").innerText; + iframe.src = "/"+id+"?dark=" + (localStorage.getItem("darkmode") === "true" ? "1" : "0"); + frame.style.display = "block"; + } + function loadDarkMode() { + let dark = localStorage.getItem("darkmode") === "true"; + let v = "0"; + if (dark) v = "1"; + let style = document.querySelector(".uwustyle"); + style.innerHTML = ":root { --dark-light: " + v + "; }"; + let color = localStorage.getItem("accent-color"); + if (color) { + style.innerHTML += ":root { --accent1: " + color + "; }"; + } + } + function toggleDarkMode() { + let dark = localStorage.getItem("darkmode") === "true"; + dark = !dark; + localStorage.setItem("darkmode", dark.toString()); + loadDarkMode() + } + function setAccentColor(color) { + if (!/^#[0-9a-fA-F]{6}$/.test(color)) { + console.error("Invalid color format. Use hex format like #ff0000."); + return; + } + localStorage.setItem("accent-color", color); + loadDarkMode(); + } +</script> +</head><body> +{{ if .Error }} +<h1>Error {{ .ErrorCode }}</h1> +<p>{{ .Error }}</p> +{{ end }} +{{ if .HtmlMetas }} +<header> + <form action="." class="pageform" method="get"> + <h1>Emails</h1> + <div class="buttholder"> + <button onclick="pageUpOrDown(this);">&lt;</button> + <span>Page {{ .Page }} of {{ .TotalPages }}</span> + <button onclick="pageUpOrDown(this);">&gt;</button> + </div> + <input type="hidden" id="pageform" name="page" value="{{ .Page }}" /> + <input type="hidden" name="to" value="{{ .ToAddr }}" /> + <input type="hidden" name="from" value="{{ .FromAddr }}" /> + <input type="hidden" name="subject" value="{{ .Subject }}" /> + <input type="hidden" name="date" value="{{ .Date }}"/> + </form> + <section class="header"> + <div class="addr" title="Full Address">Sender</div> + <div class="sub">Subject</div> + <div class="addr recpt" title="Full Address">Recipient</div> + <div class="time">Date</div> + </section> +</header> +<main style="overflow-y: auto;width: 100%;height: 100%;"> +{{ range .HtmlMetas }} +<section id="{{ .Id }}" onclick="setIframeSrc(this);"> + <div class="addr from-addr" title="{{ .FromAddr }}">{{ .FromName }}</div> + <div class="sub subject">{{ .Subject }}</div> + <div class="addr recpt to-addr" title="{{ .ToAddr }}">{{ .ToName }}</div> + <div class="time date-str">{{ .Date }}</div> +</section> +{{ end }} +</main> +<article id="frame" class="frameholder hidden"> + <header class="header"> + <span> + Subject + </span> + <span> + Date + </span> + <button onclick="setIframeSrc('');">X</button> + </header> + <iframe id="iframe"> + </iframe> +</article> +{{ end }} +{{ if .Html }} +{{ .Html }} +{{ else }} +{{ if .Text }} +<pre style="margin:0; width: 100%; height: 100%; overflow: auto;">{{ .Text }}</pre> +{{ end }} +{{ end }} +</body></html> +{{ end }}
A web/web.go

@@ -0,0 +1,248 @@

+package web + +import ( + _ "embed" + "fmt" + "html" + "html/template" + "net/http" + "net/mail" + "os" + "path/filepath" + "sophuwu.site/mailboxxer/db" + "strings" +) + +//go:embed templates/index.html +var htmlTemplate string + +var t *template.Template + +func ServeHttp() { + t = template.Must(template.New("index").Parse(htmlTemplate)) + http.ListenAndServe("127.0.1.69:3141", Http()) +} + +func E(s ...string) []any { + a := make([]any, len(s)) + for i, v := range s { + a[i] = html.EscapeString(v) + } + return a +} + +type HtmlEM struct { + Id string + Date string + Subject string + ToName string + ToAddr string + FromName string + FromAddr string +} + +func ParseInt(s string) int { + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + break + } + n = n*10 + int(r-'0') + } + return n +} + +func TempErr(w http.ResponseWriter, code int) { + msg := "An error occurred" + switch code { + case 404: + msg = "Not found" + case 500: + msg = "Internal server error" + } + var dat = map[string]any{ + "Error": msg, + "ErrorCode": code, + } + t.Execute(w, dat) +} + +func Http() http.HandlerFunc { + qu, errr := db.NewQuery(30) + if errr != nil { + fmt.Fprintln(os.Stderr, "Error creating query:", errr) + db.Close() + os.Exit(1) + } + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + r.ParseForm() + var q []string + if r.Form.Get("to") != "" { + q = append(q, fmt.Sprintf(` toaddr LIKE '%%%s%%'`, r.Form.Get("to"))) + } + if r.Form.Get("from") != "" { + q = append(q, fmt.Sprintf(` fromaddr LIKE '%%%s%%'`, r.Form.Get("from"))) + } + if r.Form.Get("subject") != "" { + q = append(q, fmt.Sprintf(` subject LIKE '%%%s%%'`, r.Form.Get("subject"))) + } + if r.Form.Get("date") != "" { + q = append(q, fmt.Sprintf(` date LIKE '%%%s%%'`, r.Form.Get("date"))) + } + where := func() string { + if len(q) == 0 { + return "" + } + return strings.Join(q, " AND ") + }() + var err error + if where != qu.GetWhere() { + err = qu.SetWhere(where) + if err != nil { + TempErr(w, 500) + return + } + } + err = qu.SetPage(ParseInt(r.Form.Get("page")) - 1) + if err != nil { + TempErr(w, 500) + return + } + if qu.TotalRows() == 0 { + TempErr(w, 404) + return + } + + var htmlMetas []HtmlEM + var from *mail.Address + var to []*mail.Address + var addrlist []string + var htmlMeta HtmlEM + for _, em := range qu.Rows() { + htmlMeta = HtmlEM{ + Id: em.Id, + Date: db.TimeStr(em.Date), + Subject: em.Subject, + } + from, err = mail.ParseAddress(em.From) + if err != nil { + htmlMeta.FromAddr = em.From + } else { + htmlMeta.FromAddr = from.Address + htmlMeta.FromName = from.Name + } + if htmlMeta.FromName == "" { + htmlMeta.FromName = htmlMeta.FromAddr + } + to, err = mail.ParseAddressList(em.To) + if err != nil || len(to) == 0 { + addrlist = strings.Split(em.To, ", ") + if len(addrlist) == 0 { + htmlMeta.ToAddr = em.To + } else { + htmlMeta.ToAddr = addrlist[0] + } + } else { + htmlMeta.ToAddr = to[0].Address + htmlMeta.ToName = to[0].Name + } + if htmlMeta.ToName == "" { + htmlMeta.ToName = htmlMeta.ToAddr + } + htmlMetas = append(htmlMetas, htmlMeta) + } + dat := map[string]any{ + "ToAddr": r.Form.Get("to"), + "FromAddr": r.Form.Get("from"), + "Subject": r.Form.Get("subject"), + "Date": r.Form.Get("date"), + "Page": qu.Page(), + "TotalPages": qu.TotalPages(), + "HtmlMetas": htmlMetas, + } + t.Execute(w, dat) + return + } + if filepath.Base(r.URL.Path) == "html.html" && len(strings.Split(r.URL.Path[1:], "/")) == 2 { + id := filepath.Base(filepath.Dir(r.URL.Path)) + s, _ := os.ReadFile(filepath.Join(db.SAVEPATH, id, "header.txt")) + b, _ := os.ReadFile(filepath.Join(db.SAVEPATH, id, "body.txt")) + fmt.Fprintf(w, `<html><head><title>%s</title></head> +<body style="display: flex; flex-direction: row;"> +<div style="width: 50%%;display:inline;height:100%%;overflow:scroll;"><pre>%s</pre><br><pre>%s</pre></div> +<iframe style="width: 50%%;display:inline;height:100%%;overflow:scroll;" src="%s"></iframe> +</body></html>`, id, string(s), string(b), "/"+id) + return + } + if func(w http.ResponseWriter, r *http.Request) bool { + var file string + var id string + l := strings.SplitN(r.URL.Path[1:], "/", 2) + if len(l) == 0 { + return false + } + if len(l) >= 1 { + id = l[0] + } + if len(l) >= 2 { + file = l[1] + } + if file != "" && filepath.Ext(file) != ".txt" { + return false + } + de, err := os.ReadDir(filepath.Join(db.SAVEPATH, id)) + if err != nil { + return false + } + + darkMode := r.URL.Query().Get("dark") + if darkMode == "" || darkMode == "0" || darkMode == "false" { + darkMode = "0" + } else { + darkMode = "1" + } + dat := map[string]any{ + "DarkMode": darkMode, + "Style": `style="overflow: auto;"`, + } + + var ss string + var f os.DirEntry + if file != "" { + var b []byte + for _, f = range de { + if f.Name() == file && f.Type().IsRegular() { + b, err = os.ReadFile(filepath.Join(db.SAVEPATH, id, file)) + break + } + } + if err != nil || len(b) == 0 { + return false + } + ss = string(b) + if filepath.Ext(f.Name()) == ".txt" { + dat["Text"] = ss + } else { + return false + } + } else { + for _, f = range de { + ss += fmt.Sprintf(`<a href="/%s`, filepath.Join(id, f.Name())) + if filepath.Ext(f.Name()) == ".txt" { + ss += fmt.Sprintf(`?dark=%s`, darkMode) + } + ss += fmt.Sprintf(`">%s</a><br>%c`, html.EscapeString(f.Name()), '\n') + } + ss = `<main ` + fmt.Sprint(dat["Style"]) + `>` + ss + `</main>` + dat["Html"] = template.HTML(ss) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, dat) + return true + }(w, r) { + return + } + http.FileServer(http.Dir(db.SAVEPATH)).ServeHTTP(w, r) + } +}