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.
290 lines
12 KiB
Go
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"})
|
|
}
|