1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
package main

// mailweb's text/llm view data.
//
// The rendering machinery — markers, escaping, paging, size hints, the HTML to
// text conversion — lives in the shared mailtext package, which every mail
// frontend in this tree uses. What stays here is what is specific to mailweb:
// the structs describing what one of its pages contains, and the templates that
// lay them out.
//
// The split is along the trust boundary rather than merely by size. The marker
// scheme is the part that must behave identically everywhere and must not be
// reimplemented per application; the shape of an index row is the part that
// legitimately differs between a backend with mailboxes and one with labels.

import (
	_ "embed"
	"net/http"
	"net/url"
	"strconv"
	"text/template"

	"codeberg.org/Profpatsch/Profpatsch/users/Profpatsch/mailtext"
)

// appName appears in every delimiter marker, so a reader can see which program
// framed the text it is looking at.
const appName = "mailweb"

// defaultHTMLLimit is the page size of the HTML message index, which is much
// smaller than the text one because a page costs far more there: every message
// is rendered in its own sandboxed iframe, so a page of n messages is n+1
// requests and n documents, against a single request for the text rendering.
// Both are only defaults; ?limit= overrides either.
const defaultHTMLLimit = 10

// ============================================================================
// Templates
// ============================================================================

//go:embed templates/index.llm
var indexLLMSrc string

//go:embed templates/contacts.llm
var contactsLLMSrc string

//go:embed templates/contact_detail.llm
var contactDetailLLMSrc string

//go:embed templates/msgbody.llm
var msgBodyLLMSrc string

//go:embed templates/forge.llm
var forgeLLMSrc string

//go:embed templates/forge_repo.llm
var forgeRepoLLMSrc string

//go:embed templates/draft.llm
var draftLLMSrc string

//go:embed templates/drafts.llm
var draftsLLMSrc string

var (
	indexLLMTmpl         = mustParseLLM("index.llm", indexLLMSrc)
	contactsLLMTmpl      = mustParseLLM("contacts.llm", contactsLLMSrc)
	contactDetailLLMTmpl = mustParseLLM("contact_detail.llm", contactDetailLLMSrc)
	msgBodyLLMTmpl       = mustParseLLM("msgbody.llm", msgBodyLLMSrc)
	forgeLLMTmpl         = mustParseLLM("forge.llm", forgeLLMSrc)
	forgeRepoLLMTmpl     = mustParseLLM("forge_repo.llm", forgeRepoLLMSrc)
	draftLLMTmpl         = mustParseLLM("draft.llm", draftLLMSrc)
	draftsLLMTmpl        = mustParseLLM("drafts.llm", draftsLLMSrc)
)

// mustParseLLM parses a .llm template with both the shared rendering helpers
// and mailweb's own {{account}} bound.
//
// The maps are layered here rather than in mailtext because the account is
// mailweb's concern and not the shared package's: mailtext is used by every
// mail frontend here, and not all of them are one process per account. Binding
// the helper on this side keeps that difference out of the trust boundary,
// which has to mean the same thing everywhere it is used.
//
// The binding survives rendering: mailtext.Write clones the template and adds
// the per-response helpers, and Funcs merges into the existing map rather than
// replacing it, so {{account}} is still there when the markers are bound.
func mustParseLLM(name, src string) *template.Template {
	return template.Must(template.New(name).
		Funcs(mailtext.ParseFuncs).
		Funcs(accountTmplFuncs).
		Parse(src))
}

// writeLLM renders one of the templates above as a text/llm response, framed
// with mailweb's name. It is a thin wrapper so that no call site has to repeat
// the application name, which every marker in the response depends on.
func writeLLM(w http.ResponseWriter, r *http.Request, tmpl *template.Template, data any) {
	mailtext.Write(w, r, appName, tmpl, data)
}

// ============================================================================
// View data
// ============================================================================

// indexLLMData is passed to templates/index.llm.
type indexLLMData struct {
	Messages []msgRow
	Paging   mailtext.Paging
	// Filter describes the active date range, "" when unfiltered.
	Filter string
	// Random marks a ?test=random sample, which is a single page: the sample is
	// redrawn per request, so mailtext.Paging it would be meaningless.
	Random bool
	// HTMLViewURL links back to the browser rendering, so the text view is not
	// a dead end for a reader who followed the link from a page.
	HTMLViewURL string
}

// contactsLLMData is passed to templates/contacts.llm.
type contactsLLMData struct {
	Contacts    []contactEntry
	ShowHidden  bool
	HiddenCount int
	Paging      mailtext.Paging
	HTMLViewURL string
}

// forgeLLMData is passed to templates/forge.llm.
type forgeLLMData struct {
	Repos       []forgeRepoEntry
	Paging      mailtext.Paging
	HTMLViewURL string
}

// forgeRepoLLMData is passed to templates/forge_repo.llm.
type forgeRepoLLMData struct {
	Repo    string
	Threads []forgeThread
	// MsgLimit is the per-thread message cap in force, which the rendering
	// states rather than leaving the reader to infer it from a truncated list.
	MsgLimit    int
	Paging      mailtext.Paging
	HTMLViewURL string
}

// contactDetailLLMData is passed to templates/contact_detail.llm.
type contactDetailLLMData struct {
	contactDetailData
	Paging      mailtext.Paging
	HTMLViewURL string
}

// msgBodyLLMData is passed to templates/msgbody.llm.
type msgBodyLLMData struct {
	ID      int64
	Mailbox string
	Subject string
	// From and To carry the petname where one is assigned and mark the
	// sender's own claim where none is; see the Names section in mailtext.
	From     mailtext.Name
	To       []mailtext.Name
	Date     string
	MimeType string
	Body     string

	HTMLViewURL string

	// ReportURL is the spam report form for this message's sender, pre-selected
	// to this message alone. It is "" when reporting makes no sense: mail from
	// the account owner, or from a sender with no usable address.
	//
	// It is a link to a *form*, not an action. See reportSpamPrefill.
	ReportURL string

	// Attachments are the parts of the message that are not the body, and
	// AttachmentsKnown says whether that list means anything: an empty list on
	// a message whose structure was never examined means "not looked", not
	// "nothing attached", and the view says which.
	Attachments      []attachment
	AttachmentsKnown bool

	// CalendarByIdx holds the parsed events of each calendar attachment, keyed
	// by the attachment's position.
	//
	// The HTML views reach the same information through a framed sub-request,
	// which lets a listing of fifty messages fetch nothing until something is
	// scrolled to. A text rendering has no frames and no lazy anything, so the
	// events are resolved here — affordable because this view is one message
	// and the fetch happens once, and worth it because reading mail through
	// this rendering is otherwise the one place that still cannot answer "when
	// is this meeting".
	CalendarByIdx map[int][]icalEvent
}

// reportSpamURL builds the spam report form URL for one message: the sender's
// contact, with this message the only one selected.
//
// The remaining fields — reason, description, illegal — are deliberately left
// out. They are the report's substance, and are for the reader of the message
// to write; a URL that arrived with them already filled in would be a report
// composed by whoever wrote this function rather than by whoever read the mail.
//
// Returns "" when the sender is the account owner or has no address, since
// there is nothing to report in either case.
func reportSpamURL(fromAddr string, msgID int64) string {
	_, address := parseFromAddr(fromAddr)
	if address == "" || isMyAddress(address) {
		return ""
	}
	q := url.Values{}
	q.Set("msg", strconv.FormatInt(msgID, 10))
	return "/contact/" + url.PathEscape(address) + "/report-spam?" + q.Encode()
}