Files
mesh-catalog/modules/mesh-mail-web/cmd/mail-page-dev/world.go
T
jochen d8144b704d issue 1859: run the mail page locally with invented mail (1859.5)
So the operator can click through the page on their own machine before it is delivered: cmd/mail-page-dev
serves internal/web's real handlers on 127.0.0.1 alone, over invented mail in two accounts and an in-memory
mail reading that fills every section, with an always-signed-in stand-in; nothing reaches the bus, a
database, Keycloak or a mail server. -now fixes today. The README says how to run it and what is a stand-in.
2026-10-22 20:14:24 +02:00

290 lines
12 KiB
Go

package main
// The invented mail the local run shows (issue 1859.5): two accounts, work and personal, and their mails —
// the mockup's own invented people and firms (design 63's mockup: Sara Claes, Tim Jacobs, Lena Janssens, the
// Harbor demo, Hostco, Telco), at addresses under .example, which no mail server answers. Nothing here is a
// real person, firm or provider. The mail-store stand-ins answer in mesh-mail's own shapes (cmd/mesh-mail
// tools.go accountsTool, searchTool, readTool; imap.go metadata), copied from that code.
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
type person struct {
Name string `json:"name,omitempty"`
Address string `json:"address"`
}
// devMail is one invented mail.
type devMail struct {
Account, Folder string
UID int
MessageID string
Date time.Time
From person
Subject, Text string
Unread bool
Attachment string // a file name, or none
}
// devAccounts are the two invented accounts.
var devAccounts = []struct{ Name, Kind, Address string }{
{"work", "imap", "op@work.example"},
{"personal", "imap", "op@mail.example"},
}
var (
sara = person{"Sara Claes", "sara.claes@harbor.example"}
tim = person{"Tim Jacobs", "tim.jacobs@harbor.example"}
lena = person{"Lena Janssens", "lena.janssens@hostco.example"}
telco = person{"Telco", "noreply@telco.example"}
digest = person{"", "digest@lists.example"} // no display name: issue 1850's case
)
// world is the invented mailbox and everything the local run keeps, in memory only.
type world struct {
mu sync.Mutex
now func() time.Time
mails []*devMail
sends map[string]int // a send's id, and how often its state was asked
read *reading
}
// mailAt is a time some hours before the run's start.
func hoursBefore(start time.Time, h int) time.Time { return start.Add(-time.Duration(h) * time.Hour) }
func newWorld(start time.Time, now func() time.Time) *world {
w := &world{now: now, sends: map[string]int{}}
uid := 100
add := func(account, folder string, hours int, from person, subject, text string, attachment string) *devMail {
uid++
m := &devMail{Account: account, Folder: folder, UID: uid, MessageID: fmt.Sprintf("<%d@%s.example>", uid, account),
Date: hoursBefore(start, hours), From: from, Subject: subject, Text: text, Unread: hours < 30, Attachment: attachment}
w.mails = append(w.mails, m)
return m
}
m := map[string]*devMail{}
m["offer"] = add("work", "INBOX", 2, sara, "The Harbor demo: the signed offer",
"Hi,\n\nAttached is the offer for the Harbor demo. Could you sign it and send it back to me today, so legal can file it?\n\nThanks,\nSara", "harbor-offer.pdf")
m["quote"] = add("work", "INBOX", 5, tim, "Re: the revised quote",
"Hi,\n\nI am working on the revised quote for the Harbor demo and will send it before Friday.\n\nTim", "")
m["hostco"] = add("work", "INBOX", 9, lena, "Hostco renewal for next year",
"Hello,\n\nYour Hostco plan renews on 1 November. Please approve the renewal by Saturday; the invoice is attached for your finance team.\nThe new contract is on its way by post.\n\nKind regards,\nLena Janssens\nHostco", "hostco-invoice.pdf")
m["room"] = add("work", "INBOX", 20, sara, "A room for the demo",
"Could you book a room for the Harbor demo, two weeks from now? Twelve people.\n\nSara", "")
m["slides"] = add("work", "INBOX", 26, tim, "The demo slides",
"Thanks for the call. You said you would send me the demo slides tomorrow — looking forward to them.\n\nTim", "")
m["newsletter"] = add("work", "INBOX", 30, person{"Harbor news", "news@harbor.example"}, "Harbor news: the autumn issue",
"The autumn issue: how the Harbor team moved its demo lab, and three talks worth watching.", "")
m["router"] = add("personal", "INBOX", 4, telco, "Your new router ships today",
"Your Telco router left our warehouse today and arrives tomorrow.\n\nTelco", "")
m["digest"] = add("work", "INBOX", 12, digest, "Weekly digest",
"This week on the list: four threads about release notes, one about a new mirror.", "")
m["parcel"] = add("personal", "INBOX", 3, person{"Parcel service", "parcel@deliver-fast.example"}, "Your parcel is held: pay 2 EUR",
"Your parcel is held at our depot. Pay the fee of 2 EUR at the link below within 24 hours or it is sent back.", "")
m["thanks"] = add("work", "INBOX", 40, lena, "Thanks for Tuesday",
"Thanks for the meeting on Tuesday. Nothing more is needed from you for now.\n\nLena", "")
m["agenda"] = add("work", "INBOX", 6, sara, "Kick-off agenda",
"Here is the agenda for the kick-off. Could you check the time slots and tell me which suits you?\n\nSara", "")
m["bill"] = add("personal", "INBOX", 8, person{"Telco billing", "billing@telco.example"}, "Your Telco bill for October",
"Your bill for October is 34.50 EUR, due on the 28th.", "telco-october.pdf")
m["photos"] = add("personal", "INBOX", 28, lena, "Photos from the weekend",
"A few photos from the weekend at the coast, no hurry.\n\nLena", "")
m["club"] = add("personal", "INBOX", 50, person{"The book club", "club@readers.example"}, "Book club: next month's book",
"Next month we read a short novel; the meeting stays on the first Thursday.", "")
m["dentist"] = add("personal", "INBOX", 7, person{"Smile practice", "reception@smile.example"}, "Please confirm your appointment",
"Please confirm your appointment of next Tuesday at 9:30 by replying to this mail.", "")
// The rest of the inboxes and an archive: mail reading's list does not name them, All mail does.
add("work", "INBOX", 60, tim, "Lunch on Friday?", "Lunch on Friday with the Harbor team?", "")
add("work", "INBOX", 72, person{"Facilities", "facilities@work.example"}, "The lift is serviced on Monday", "The lift is out of use on Monday morning.", "")
add("work", "Archive", 200, sara, "The Harbor demo: first ideas", "First ideas for the demo, for later.", "")
add("work", "Archive", 260, lena, "Hostco: your plan", "Your current Hostco plan, for your records.", "")
add("work", "Archive", 300, tim, "Old quote", "The first quote, kept for the record.", "")
add("personal", "INBOX", 80, person{"Telco", "noreply@telco.example"}, "Your order is confirmed", "Your router order is confirmed.", "")
add("personal", "Archive", 400, person{"The book club", "club@readers.example"}, "Book club: last month's notes", "The notes of last month's meeting.", "")
add("personal", "Archive", 500, lena, "Old photos", "Some old photos.", "")
w.read = newReading(start, now, m)
return w
}
// ---- mail-store's verbs (cmd/mesh-mail/tools.go) --------------------------------------------------------------
func metadataOf(m *devMail) map[string]any {
flags := []string{}
if !m.Unread {
flags = append(flags, `\Seen`)
}
atts := []map[string]any{}
if m.Attachment != "" {
atts = append(atts, map[string]any{"bucket": "mail-attachments", "key": "att-" + fmt.Sprint(m.UID), "size": 18432,
"type": "application/pdf", "name": m.Attachment})
}
return map[string]any{
"account": m.Account, "folder": m.Folder, "uid": m.UID, "uidvalidity": 1, "message-id": m.MessageID,
"in-reply-to": "", "date": m.Date.UTC().Format(time.RFC3339), "from": []person{m.From},
"to": []person{{Address: accountAddress(m.Account)}}, "cc": []person{}, "subject": m.Subject,
"size": len(m.Text) + 600, "flags": flags, "attachments": atts,
}
}
func accountAddress(name string) string {
for _, a := range devAccounts {
if a.Name == name {
return a.Address
}
}
return ""
}
func answer(v any) (json.RawMessage, error) { return json.Marshal(v) }
func (w *world) accounts(context.Context) (json.RawMessage, error) {
var out []map[string]any
for _, a := range devAccounts {
out = append(out, map[string]any{"account": a.Name, "kind": a.Kind, "address": a.Address, "state": "current",
"why": "the local run's own invented mail", "folders": []string{"INBOX", "Archive"}})
}
return answer(map[string]any{"accounts": out, "caller": "mesh-mail-web"})
}
// bucket answers mesh-mail's account bucket (mirror.go AccountValue): each folder's mail and unread counts.
func (w *world) bucket(account string) (*stdio.StateEntry, error) {
w.mu.Lock()
defer w.mu.Unlock()
counts := map[string][2]int{}
for _, m := range w.mails {
if m.Account == account {
c := counts[m.Folder]
c[0]++
if m.Unread {
c[1]++
}
counts[m.Folder] = c
}
}
var folders []map[string]any
for _, f := range []string{"INBOX", "Archive"} {
folders = append(folders, map[string]any{"name": f, "mails": counts[f][0], "unread": counts[f][1]})
}
raw, _ := json.Marshal(map[string]any{"state": "current", "folders": folders})
return &stdio.StateEntry{Key: account, Value: raw}, nil
}
func text(args map[string]any, key string) string { s, _ := args[key].(string); return s }
func (w *world) search(_ context.Context, args map[string]any) (json.RawMessage, error) {
w.mu.Lock()
defer w.mu.Unlock()
limit := 20
if l, ok := args["limit"].(float64); ok {
limit = int(l)
}
query := strings.ToLower(text(args, "query"))
var before time.Time
if s := text(args, "before"); s != "" {
before, _ = time.Parse(time.RFC3339, s)
}
var found []*devMail
for _, m := range w.mails {
if a := text(args, "account"); a != "" && m.Account != a {
continue
}
if f := text(args, "folder"); f != "" && m.Folder != f {
continue
}
if !before.IsZero() && !m.Date.Before(before) {
continue
}
hay := strings.ToLower(m.Subject + " " + m.Text + " " + m.From.Name + " " + m.From.Address)
if query != "" && !strings.Contains(hay, query) {
continue
}
found = append(found, m)
}
sort.SliceStable(found, func(i, j int) bool { return found[i].Date.After(found[j].Date) })
if len(found) > limit {
found = found[:limit]
}
out := make([]map[string]any, len(found))
for i, m := range found {
out[i] = metadataOf(m)
}
return answer(map[string]any{"searched": []string{"work", "personal"}, "found": out})
}
func (w *world) mailAt(account, folder string, uid int) *devMail {
for _, m := range w.mails {
if m.Account == account && m.Folder == folder && m.UID == uid {
return m
}
}
return nil
}
func number(args map[string]any, key string) int {
switch v := args[key].(type) {
case float64:
return int(v)
case int:
return v
case int64:
return int(v)
}
return 0
}
func (w *world) readMail(_ context.Context, args map[string]any) (json.RawMessage, error) {
w.mu.Lock()
defer w.mu.Unlock()
m := w.mailAt(text(args, "account"), text(args, "folder"), number(args, "uid"))
if m == nil {
return nil, fmt.Errorf("the account %s holds no message %d in %s", text(args, "account"), number(args, "uid"), text(args, "folder"))
}
if key := text(args, "attachment"); key != "" {
if m.Attachment == "" || key != "att-"+fmt.Sprint(m.UID) {
return nil, fmt.Errorf("the message holds no attachment %s", key)
}
body := "%PDF-1.4\n% an invented attachment of the local run: " + m.Attachment + "\n"
return answer(map[string]any{"attachment": map[string]any{"key": key, "name": m.Attachment, "size": len(body),
"type": "application/pdf"}, "base64": base64.StdEncoding.EncodeToString([]byte(body))})
}
m.Unread = false
out := metadataOf(m)
out["text"] = m.Text
return answer(out)
}
// send answers a send's id at once: the operator's Approve is the real mesh's, never asked here.
func (w *world) send(_ context.Context, args map[string]any) (json.RawMessage, error) {
w.mu.Lock()
defer w.mu.Unlock()
id := fmt.Sprintf("local-send-%d", len(w.sends)+1)
w.sends[id] = 0
return answer(map[string]any{"ask": id})
}
// sendStatus walks a send to sent: waiting when first asked, sent after that. Nothing is ever sent.
func (w *world) sendStatus(_ context.Context, args map[string]any) (json.RawMessage, error) {
w.mu.Lock()
defer w.mu.Unlock()
id := text(args, "id")
n, ok := w.sends[id]
if !ok {
return answer(map[string]any{"status": "unknown", "words": "no send of that id"})
}
w.sends[id] = n + 1
if n == 0 {
return answer(map[string]any{"status": "waiting", "words": "the operator's Approve (the local run approves it by itself)"})
}
return answer(map[string]any{"status": "sent", "words": "the local run sends nothing: no mail left this machine"})
}