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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
package main

// Who a message was between, and how each view says it.
//
// Three views answer this question — the index, the conversation page and a
// message's own page — and they used to answer it differently: one printed the
// sender and nothing else, one decided direction from the mailbox a message
// happened to be filed in, and one printed recipients as a preformatted string
// that the reader could not tell from a name they had assigned themselves.
// These tests pin all three to the same answers, because the failures are all
// of the same kind: a page stating something about a correspondent that is not
// true.

import (
	"strings"
	"testing"
	"text/template"
	"time"

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

// renderLLM executes a text-rendering template the way a handler does: the
// name helpers are bound per response, because the region markers they escape
// against carry a token that only exists for one response. Rendering one
// without binding them fails on the first {{name}}.
func renderLLM(t *testing.T, tmpl *template.Template, data any) string {
	t.Helper()
	d, err := mailtext.NewDelims(appName)
	if err != nil {
		t.Fatalf("delims: %v", err)
	}
	bound, err := tmpl.Clone()
	if err != nil {
		t.Fatalf("clone: %v", err)
	}
	var sb strings.Builder
	if err := bound.Funcs(mailtext.Funcs(d)).Execute(&sb, data); err != nil {
		t.Fatalf("%s: %v", tmpl.Name(), err)
	}
	return sb.String()
}

// withAccount sets the account identity for a test and restores it, since it
// is process-global and several tests here need different views of it.
func withAccount(t *testing.T, primary string, aliases ...string) {
	t.Helper()
	oldAddr, oldAddrs, oldSent := myAddress, myAddresses, sentMailbox
	t.Cleanup(func() { myAddress, myAddresses, sentMailbox = oldAddr, oldAddrs, oldSent })
	myAddress = primary
	myAddresses = map[string]bool{primary: true}
	for _, a := range aliases {
		myAddresses[a] = true
	}
	sentMailbox = "Sent"
	accountName = "test"
}

// outgoingRow is a message this account wrote, to one person with another
// copied.
func outgoingRow() msgRow {
	return msgRow{
		ID: 31302, Subject: "Bitte um Belegungsdaten",
		FromAddr:  "me@example.org",
		From:      resolveAddress(nil, "me@example.org", ""),
		To:        resolveDisplays(nil, []addr{{Address: "opendata@example.org"}}),
		Cc:        resolveDisplays(nil, []addr{{Address: "smartcity@example.org"}}),
		Direction: "sent",
		Date:      time.Date(2026, 9, 18, 21, 35, 0, 0, time.UTC),
	}
}

// TestIsOutgoingAsksTheHeaderNotTheMailbox is the rule the views rest on.
//
// It used to be mailbox == sentMailbox, which made mail this account wrote and
// filed anywhere else read as mail the correspondent sent — this archive has
// 44 such messages outside the Sent folder.
func TestIsOutgoingAsksTheHeaderNotTheMailbox(t *testing.T) {
	withAccount(t, "me@example.org", "alias@example.org")

	for _, c := range []struct {
		name     string
		fromAddr string
		want     bool
	}{
		{"the account itself", "me@example.org", true},
		{"with a display name", "Me <me@example.org>", true},
		{"a known alias", "alias@example.org", true},
		{"differing in case", "ME@Example.ORG", true},
		{"somebody else", "klara@example.org", false},
		{"an address that merely looks like one", "me@example.org.evil.test", false},
		{"nothing at all", "", false},
	} {
		if got := isOutgoing(c.fromAddr); got != c.want {
			t.Errorf("%s: isOutgoing(%q) = %v, want %v", c.name, c.fromAddr, got, c.want)
		}
	}
}

// TestSentMailShowsRecipientsNotYourself is the bug this was reported as: the
// index printed "From: <your own address>" for everything you had written,
// which is the one fact the reader already has.
func TestSentMailShowsRecipientsNotYourself(t *testing.T) {
	withAccount(t, "me@example.org")
	msg := outgoingRow()

	for name, out := range map[string]string{
		"index": renderTemplate(t, "index", indexPageData{
			Messages: []msgRow{msg}, Paging: mailtext.Paging{Total: 1},
		}),
		"contact": renderTemplate(t, "contactdetail", contactDetailData{
			Contact: "opendata@example.org", AddrEscaped: "opendata%40example.org",
			Messages: []msgRow{msg},
		}),
		"msgview": renderTemplate(t, "msgview", msgViewData{Msg: msg, Mailbox: "Sent"}),
	} {
		if !strings.Contains(out, "opendata@example.org") {
			t.Errorf("%s: should name who the mail went to", name)
		}
		if !strings.Contains(out, "smartcity@example.org") {
			t.Errorf("%s: should name who was copied", name)
		}
	}

	// The listings lead with the recipients rather than the sender. The
	// message's own page still has a From row — it is a full header block, and
	// there the sender is one field among several rather than the whole line.
	for name, out := range map[string]string{
		"index": renderTemplate(t, "index", indexPageData{
			Messages: []msgRow{msg}, Paging: mailtext.Paging{Total: 1},
		}),
		"contact": renderTemplate(t, "contactdetail", contactDetailData{
			Contact: "opendata@example.org", AddrEscaped: "opendata%40example.org",
			Messages: []msgRow{msg},
		}),
	} {
		if strings.Contains(out, "From: ") {
			t.Errorf("%s: outgoing mail should not be labelled with its sender", name)
		}
	}
}

// TestCcIsShownOnIncomingMail. 20578 of 28978 messages in this author's
// archive carry a Cc and none of them used to be shown one, so a letter
// written to the reader and one they were merely copied on looked identical.
func TestCcIsShownOnIncomingMail(t *testing.T) {
	withAccount(t, "me@example.org")
	msg := testMsgRow()
	msg.Direction = "received"
	msg.To = resolveDisplays(nil, []addr{{Address: "me@example.org"}})
	msg.Cc = resolveDisplays(nil, []addr{{Address: "team@example.org"}})

	for name, out := range map[string]string{
		"index": renderTemplate(t, "index", indexPageData{
			Messages: []msgRow{msg}, Paging: mailtext.Paging{Total: 1},
		}),
		"contact": renderTemplate(t, "contactdetail", contactDetailData{
			Contact: "klara@example.org", AddrEscaped: "klara%40example.org",
			Messages: []msgRow{msg},
		}),
		"msgview": renderTemplate(t, "msgview", msgViewData{Msg: msg, Mailbox: "INBOX"}),
	} {
		if !strings.Contains(out, "team@example.org") {
			t.Errorf("%s: should say who else was copied", name)
		}
		if !strings.Contains(out, "klara@example.org") {
			t.Errorf("%s: incoming mail is still labelled with its sender", name)
		}
	}
}

// TestBlindCopyIsStated. A message with no visible recipient header reached
// this account by the envelope alone; without saying so it is exactly what a
// letter addressed to the reader personally looks like.
func TestBlindCopyIsStated(t *testing.T) {
	withAccount(t, "me@example.org")
	msg := testMsgRow()
	msg.Direction = "received"
	msg.To, msg.Cc = nil, nil
	msg.DeliveredTo = "me@example.org"

	for name, out := range map[string]string{
		"index": renderTemplate(t, "index", indexPageData{
			Messages: []msgRow{msg}, Paging: mailtext.Paging{Total: 1},
		}),
		"contact": renderTemplate(t, "contactdetail", contactDetailData{
			Contact: "klara@example.org", AddrEscaped: "klara%40example.org",
			Messages: []msgRow{msg},
		}),
		"msgview": renderTemplate(t, "msgview", msgViewData{Msg: msg, Mailbox: "INBOX"}),
	} {
		if !strings.Contains(out, "undisclosed recipients") {
			t.Errorf("%s: should say no visible header named the reader", name)
		}
		if !strings.Contains(out, "blind copy") {
			t.Errorf("%s: should explain what that means", name)
		}
	}
}

// TestRecipientsAreMarkedLikeSenders is the security property, not a cosmetic
// one, and it is the reason msgRow carries Names rather than a formatted
// string. The conversation page printed the recipients of outgoing mail as a
// preformatted "Name <addr>" run built straight from somebody else's header,
// in the same voice as a petname the reader had assigned. See name.html.
func TestRecipientsAreMarkedLikeSenders(t *testing.T) {
	withAccount(t, "me@example.org")
	msg := outgoingRow()
	// A display name written by whoever holds the address, not by the reader.
	msg.To = resolveDisplays(nil, []addr{
		{Name: "Deutsche-Bank AG", Address: "opendata@example.org"},
	})
	// And one the reader did assign, to the copied address.
	msg.Cc = resolveDisplays(map[string]string{"smartcity@example.org": "smartcity"},
		[]addr{{Address: "smartcity@example.org"}})

	for name, out := range map[string]string{
		"index": renderTemplate(t, "index", indexPageData{
			Messages: []msgRow{msg}, Paging: mailtext.Paging{Total: 1},
		}),
		"contact": renderTemplate(t, "contactdetail", contactDetailData{
			Contact: "opendata@example.org", AddrEscaped: "opendata%40example.org",
			Messages: []msgRow{msg},
		}),
	} {
		// The claim is quoted and the assigned name is not: that difference is
		// the whole point, and a bare claimed name is the state this replaced.
		if !strings.Contains(out, `class="claimed"`) {
			t.Errorf("%s: a recipient's own display name must be marked as a claim", name)
		}
		if !strings.Contains(out, `class="pet"`) {
			t.Errorf("%s: a petname must be marked as one", name)
		}
	}
}

// TestTextRenderingsAgreeWithTheHTML. The two are the same information, and a
// reader choosing the cheaper rendering must not get a different answer about
// who a message was between.
func TestTextRenderingsAgreeWithTheHTML(t *testing.T) {
	withAccount(t, "me@example.org")
	msg := outgoingRow()

	out := renderLLM(t, indexLLMTmpl, indexLLMData{
		Messages: []msgRow{msg}, Paging: mailtext.Paging{Total: 1},
	})
	if !strings.Contains(out, "opendata@example.org") {
		t.Error("index text rendering should name who the mail went to")
	}
	if !strings.Contains(out, "smartcity@example.org") {
		t.Error("index text rendering should name who was copied")
	}

	body := renderLLM(t, msgBodyLLMTmpl, msgBodyLLMData{
		ID: msg.ID, Subject: msg.Subject, From: msg.From,
		To: msg.To, Cc: msg.Cc, Mailbox: "Sent", Date: "2026-09-18 21:35",
	})
	if !strings.Contains(body, "Cc:") {
		t.Error("a message's text rendering should carry its Cc")
	}
}

// TestEditorDoesNotSizeItsOwnTextareas.
//
// A block grows to fit its content because the stylesheet says field-sizing:
// content. The editor used to do it by measuring scrollHeight and assigning
// style.height, and both halves were wrong: it measured before the element was
// in the document, where scrollHeight is 0, so every block opened collapsed to
// its floor; and it set height:auto to measure, which shortened the document
// for a frame and made the browser clamp the page's scroll — felt as the
// cursor jumping to the end of a long draft on every keystroke.
//
// This is a test rather than a comment because the mistake is an easy one to
// make again: "grow a textarea to fit" is a thing people reach for JavaScript
// for out of habit, and the habit predates the CSS property by a decade.
func TestEditorDoesNotSizeItsOwnTextareas(t *testing.T) {
	js, err := staticFS.ReadFile("static/draft-editor.js")
	if err != nil {
		t.Fatalf("read editor script: %v", err)
	}
	src := string(js)
	// Comments explain what this replaced, so only the code is examined.
	var code strings.Builder
	for line := range strings.SplitSeq(src, "\n") {
		if strings.HasPrefix(strings.TrimSpace(line), "//") {
			continue
		}
		code.WriteString(line)
		code.WriteString("\n")
	}
	for _, forbidden := range []string{"scrollHeight", "style.height", "autoGrow"} {
		if strings.Contains(code.String(), forbidden) {
			t.Errorf("the editor must not lay out its own textareas, found %q. "+
				"Sizing a block to its content is field-sizing: content in "+
				"templates/editor.html", forbidden)
		}
	}

	// And the stylesheet must actually carry it, or nothing sizes anything.
	if !strings.Contains(editorTmplSrc, "field-sizing: content") {
		t.Error("editorStyle should size blocks to their content")
	}
}