package web // The write page and a send's state (novox/hq ADR 0440 §6, issue 1267): the form offers only the accounts // mail-store.accounts answers; its POST (behind issue 1284's own per-session token, wired in here as this // form's hidden field) calls mail-store.send, which raises the operator's Approve on Telegram and sends // nothing itself, then shows the send's state from mail-store.send-status — in words, refreshing itself every // 5 seconds with no script (a meta refresh) until a final state (sent, failed, refused, not proven, declined, // expired, unknown; novox/hq ADR 0436 §12, issue 1261). import ( "context" "encoding/json" "html/template" "net/http" "net/url" "strings" "time" ) // SendStatusRefreshEvery is how often the state page asks again while a send is still waiting (ADR 0440 §6). const SendStatusRefreshEvery = 5 var writeTemplate = template.Must(template.New("write").Parse(` Write mail

Write

{{if .Error}}

{{.Error}}

{{else}}
{{.TokenField}}
{{end}} `)) type writeTemplateData struct { Accounts []string TokenField template.HTML Error string } var sendStatusTemplate = template.Must(template.New("send-status").Parse(` Send{{if .Refresh}}{{end}} {{if .Note}}

{{.Note}}

{{end}}

{{.Words}}

`)) type sendStatusData struct { Note, Words, RefreshURL string RefreshSeconds int Refresh bool } // grantedAccountNames is mail-store.accounts' own answer, read as the list of account names it granted — // shared by the write page (issue 1267/AC1) and the send handler (AC4). func (b *Bundle) grantedAccountNames(ctx context.Context) ([]string, error) { raw, err := b.accountsAsk(ctx) if err != nil { return nil, err } var answer accountsAnswer if err := json.Unmarshal(raw, &answer); err != nil { return nil, err } names := make([]string, 0, len(answer.Accounts)) for _, a := range answer.Accounts { names = append(names, a.Account) } return names, nil } func containsName(names []string, name string) bool { for _, n := range names { if n == name { return true } } return false } // splitAddresses is the write form's own "to" field (one text input) split into mail-store.send's own array // argument, on whitespace or commas. func splitAddresses(s string) []string { var out []string for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' }) { if part != "" { out = append(out, part) } } return out } // writePage is issue 1267/AC1: offers exactly the accounts mail-store.accounts answers, and (issue 1284) the // signed-in session's own form token as a hidden field, so the POST below can tell this session's own form // from any other's. func (b *Bundle) writePage(w http.ResponseWriter, r *http.Request) { data := writeTemplateData{} if sess, ok := SessionFrom(r); ok { data.TokenField = FormTokenInput(sess) } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() names, err := b.grantedAccountNames(ctx) if err != nil { data.Error = "the granted accounts could not be answered" } else { data.Accounts = names } renderPage(w, writeTemplate, data) } // sendHandler is issue 1267/AC2, AC4: called only once RequireFormToken (issue 1284) has already let the POST // through. An account mail-store did not answer sends nothing (AC4); otherwise mail-store.send is called with // exactly account, to, subject and body, and the answer is the send's own state page, noting that the // operator's Approve is asked on Telegram (AC2). func (b *Bundle) sendHandler(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } account := r.PostFormValue("account") to := r.PostFormValue("to") subject := r.PostFormValue("subject") body := r.PostFormValue("body") ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() granted, err := b.grantedAccountNames(ctx) if err != nil || !containsName(granted, account) { renderPage(w, sendStatusTemplate, sendStatusData{Words: "the account is not granted"}) return } raw, err := b.sendAsk(ctx, map[string]any{ "account": account, "to": splitAddresses(to), "subject": subject, "body": body, }) if err != nil { renderPage(w, sendStatusTemplate, sendStatusData{Words: "the send could not be asked"}) return } var answer struct { Ask string `json:"ask"` } _ = json.Unmarshal(raw, &answer) b.renderSendStatus(w, answer.Ask, "The operator's Approve is asked on Telegram.") } // sendStatusPage is GET /send-status?id=...: the send's own state page, polled by its own meta refresh (issue // 1267/AC3) until a final state. func (b *Bundle) sendStatusPage(w http.ResponseWriter, r *http.Request) { b.renderSendStatus(w, r.URL.Query().Get("id"), "") } // renderSendStatus is issue 1267/AC3: the send's state in words from mail-store.send-status; "waiting" (not // yet final) refreshes itself every SendStatusRefreshEvery seconds with a meta refresh, no script; every // other state (sent, failed, refused, not proven, declined, expired, unknown) is final and does not refresh. func (b *Bundle) renderSendStatus(w http.ResponseWriter, id, note string) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() data := sendStatusData{Note: note} raw, err := b.sendStatusAsk(ctx, map[string]any{"id": id}) if err != nil { data.Words = "the send's state could not be answered" renderPage(w, sendStatusTemplate, data) return } var answer struct { Status string `json:"status"` Words string `json:"words"` } _ = json.Unmarshal(raw, &answer) data.Words = answer.Status if answer.Words != "" { data.Words += ": " + answer.Words } if answer.Status == "waiting" { data.Refresh = true data.RefreshSeconds = SendStatusRefreshEvery data.RefreshURL = "/send-status?id=" + url.QueryEscape(id) } renderPage(w, sendStatusTemplate, data) }