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
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
package main

// The draft routes.
//
// Creating, reading, editing and discarding a draft. Sending is deliberately
// not here: it is the act, it is the only thing in this feature that cannot be
// undone, and it lives beside the code that builds the message. See "Composing
// without committing" in mailweb(1).
//
// Everything in this file is inert. It writes to the local database and nothing
// leaves the machine, which is why these routes may be driven by anything that
// can reach the listen address — including a model reading through the text
// rendering, which is the point: composing a reply is reading and writing prose,
// and deciding to send it is not.

import (
	"database/sql"
	"fmt"
	"log"
	"net/http"
	"strconv"
	"strings"

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

// draftOf resolves the {token} of a draft route, answering the request itself
// when it names nothing.
//
// The 404 lives here rather than in each handler because it is a rule about
// what a token discloses, not an error path: a token that names nothing and one
// that named a draft since discarded get the same answer, so a correct guess is
// not distinguishable from a wrong one. Three copies of that would be three
// chances for one to say something more helpful and give the game away.
func (s *server) draftOf(w http.ResponseWriter, r *http.Request) (*draft, bool) {
	d, err := loadDraft(s.db.Read, r.PathValue("token"))
	if err == sql.ErrNoRows {
		http.NotFound(w, r)
		return nil, false
	}
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return nil, false
	}
	return d, true
}

// handleDraftView renders one draft.
func (s *server) handleDraftView(w http.ResponseWriter, r *http.Request) {
	d, ok := s.draftOf(w, r)
	if !ok {
		return
	}

	petnames, err := loadPetnames(s.db.Read)
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	data := draftViewData{
		Draft:       d,
		From:        s.fromAddr,
		Blocks:      describeBlocks(d, petnames),
		Sets:        describeSets(d, petnames),
		SMTPEnabled: s.smtp.host != "",
	}

	if mailtext.WantsLLM(r) {
		data.HTMLViewURL = mailtext.HTMLViewURL(r)
		writeLLM(w, r, draftLLMTmpl, data)
		return
	}
	mailtext.SetAlternate(w, r.URL.Path)
	data.LLMViewURL = mailtext.LLMViewURL(r)
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := draftTmpl.Execute(w, data); err != nil {
		log.Printf("draft template: %v", err)
	}
}

// handleDraftDiscard deletes a draft.
func (s *server) handleDraftDiscard(w http.ResponseWriter, r *http.Request) {
	d, ok := s.draftOf(w, r)
	if !ok {
		return
	}
	if err := deleteDraft(s.db.Write, d.ID); err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, "/drafts", http.StatusSeeOther)
}

// handleDraftSend sends a draft to one of its recipient sets.
//
// This is the act. Everything else in this file is inert and may be driven by
// anything that can reach the listen address; this hands mail to a third party
// under the account owner's name and cannot be recalled. It is reached from a
// button on the draft's own page, beside the list of who it writes to.
//
// What it will not do is decide who to send to. The request names a set by the
// name it was shown under, and the addresses come from the rows that produced
// that label — never from the request. An address in a request body would be an
// address nobody read before it was used.
func (s *server) handleDraftSend(w http.ResponseWriter, r *http.Request) {
	d, ok := s.draftOf(w, r)
	if !ok {
		return
	}
	if s.smtp.host == "" {
		http.Error(w,
			"SMTP is not configured for this account (missing --smtp-host), so "+
				"nothing can be sent. The draft is unchanged.",
			http.StatusServiceUnavailable)
		return
	}
	// Sending twice is refused rather than deduplicated: the second send is a
	// second mail to the same people, and nothing about the first would say so.
	if d.Sent() {
		http.Error(w,
			"this draft has already been sent, at "+d.SentAt.Format("2006-01-02 15:04")+
				". Sending it again would deliver a second copy; reply again from the "+
				"message instead.",
			http.StatusConflict)
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest)
		return
	}

	// The set is named, and the name is resolved against what this draft
	// actually holds. A name that is not one of its sets is refused rather than
	// defaulted: guessing which set was meant is how a reply intended for one
	// person reaches a mailing list.
	name := strings.TrimSpace(r.FormValue("set"))
	if name == "" {
		http.Error(w,
			"no recipient set named. Send `set=` with one of the names the draft "+
				"lists, e.g. set=sender.",
			http.StatusBadRequest)
		return
	}
	set, ok := d.Recipients[name]
	if !ok || set.Empty() {
		http.Error(w,
			"this draft has no recipient set called "+strconv.Quote(name)+
				". Open the draft to see which sets it offers; nothing was sent.",
			http.StatusBadRequest)
		return
	}

	raw, messageID, err := buildReply(d, s.fromAddr, set)
	if err != nil {
		http.Error(w, fmt.Sprintf("could not build the message: %v", err),
			http.StatusInternalServerError)
		return
	}

	// Everyone named, To and Cc alike, receives the mail: the distinction is a
	// display convention in the header and not an addressing one.
	envelope := append(append([]Recipient{}, set.To...), set.Cc...)
	if err := sendRaw(s.smtp, s.fromAddr, envelope, raw); err != nil {
		log.Printf("draft %s: send failed: %v", d.Token, err)
		// The draft is left exactly as it was, and is not marked sent. A
		// refusal during the SMTP conversation means nothing was handed over;
		// see "A sent report is not a delivered one" for the failure this does
		// not cover.
		http.Error(w,
			"the mail server refused the message, so nothing was sent and the draft "+
				"is unchanged. The server said: "+err.Error(),
			http.StatusBadGateway)
		return
	}

	// Sent. From here on the mail exists and nothing below may fail the
	// request, because failing it would report the opposite of what happened.
	if err := markDraftSent(s.db.Write, d.ID, messageID); err != nil {
		log.Printf("draft %s: sent but not recorded: %v", d.Token, err)
	}
	if err := appendToSent(s.imapCreds, raw); err != nil {
		log.Printf("draft %s: sent but not copied to the sent mailbox: %v", d.Token, err)
	}

	if mailtext.WantsLLM(r) {
		w.Header().Set("Content-Type", mailtext.MediaType)
		fmt.Fprintf(w, "sent to %s\n", strings.Join(recipientAddresses(envelope), ", "))
		return
	}
	http.Redirect(w, r, "/draft/"+d.Token, http.StatusSeeOther)
}

// handleDrafts lists the unsent drafts.
func (s *server) handleDrafts(w http.ResponseWriter, r *http.Request) {
	drafts, err := listDrafts(s.db.Read)
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}
	data := draftListData{Drafts: drafts}
	if mailtext.WantsLLM(r) {
		data.HTMLViewURL = mailtext.HTMLViewURL(r)
		writeLLM(w, r, draftsLLMTmpl, data)
		return
	}
	mailtext.SetAlternate(w, "/drafts")
	data.LLMViewURL = mailtext.LLMViewURL(r)
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := draftsTmpl.Execute(w, data); err != nil {
		log.Printf("drafts template: %v", err)
	}
}

// handleReply creates a draft replying to a message: POST /msg/{id}/reply.
//
// This is a POST that a text client is invited to make, which is a departure
// from what the text rendering used to advertise and is the reason the manual's
// rule is stated the way it is. Nothing is sent. What comes back is a URL, and
// the draft behind it can be read, edited, discarded or ignored; a draft nobody
// opens simply sits there.
//
// The body may carry the reply's text, so that a client composing one does not
// have to make a second request to fill it in, and does not have to put a
// letter in a query string.
func (s *server) handleReply(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")
	if r.Method != http.MethodPost {
		// The GET is registered alongside the POST purely so that this can be
		// said. Refusing it is deliberate — a reply draft is created, not
		// filled in, and a GET that created one would mean any link or
		// prefetch could litter the database — and the mux's own 405 would
		// refuse it without ever explaining where to go instead.
		http.Error(w,
			"reply is created with POST /msg/"+idStr+"/reply, which composes a "+
				"draft and sends nothing. The draft it answers with is where a "+
				"reply is written and where it is sent from.",
			http.StatusMethodNotAllowed)
		return
	}

	msgID, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		http.Error(w, "invalid message id", http.StatusBadRequest)
		return
	}
	parent, err := loadParent(s.db.Read, msgID)
	if err == sql.ErrNoRows {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	// The body has to be cached before it can be quoted. A message nobody has
	// opened has none, and fetching it here is the same two-pass fetch opening
	// it would do — so replying to an unread message quotes it correctly rather
	// than quoting nothing.
	if parent.Body == "" {
		body, mime, ferr := fetchSingleBody(s.db.Write, s.pool, msgRef{
			id:   parent.ID,
			mbox: Mailbox{Name: parent.Mailbox, UIDValidity: parent.UIDValidity},
			uid:  parent.UID,
		})
		if ferr == nil {
			parent.Body, parent.BodyMime = string(body), mime
		} else {
			// Not fatal: a reply with no quote is still a reply, and refusing
			// to compose one because the server was unreachable would be worse
			// than composing it without the quoted text.
			log.Printf("reply: could not fetch body of msg %d to quote: %v", msgID, ferr)
		}
	}

	// Note that no petnames are loaded here. Nothing stored in a draft may
	// depend on one: what is stored is what gets sent, and a petname never
	// travels. They are resolved when a draft is displayed, not when it is
	// made.
	d := newReplyDraft(parent)

	// An optional body, so that one request composes the whole reply.
	if err := r.ParseForm(); err == nil {
		if body := r.FormValue("body"); strings.TrimSpace(body) != "" {
			d.Blocks[0].Content = body
		}
		if subject := strings.TrimSpace(r.FormValue("subject")); subject != "" {
			d.Subject = subject
		}
	}

	if err := createDraft(s.db.Write, d); err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	url := "/draft/" + d.Token
	if mailtext.WantsLLM(r) {
		// Answer with the URL rather than redirecting: a client that just
		// composed a draft wants to know where it is, and a 303 into a
		// rendering it did not ask for is not that.
		w.Header().Set("Content-Type", mailtext.MediaType)
		fmt.Fprintf(w, "draft created: %s\n\nNothing has been sent. Open it to "+
			"read what will go out, and to whom.\n", url)
		return
	}
	http.Redirect(w, r, url, http.StatusSeeOther)
}

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

// draftViewData is passed to the draft templates.
type draftViewData struct {
	Draft *draft
	// From is the address the reply will be sent from, which is stated because
	// an account with several addresses sends from exactly one of them.
	From string
	// Blocks are the draft's contents prepared for display, which is not quite
	// the same as its contents: a quote is attributed to a petname on screen
	// and to the sender's own name on the wire.
	Blocks []describedBlock
	// Sets are the send options, in the order they are offered.
	Sets []describedSet
	// SMTPEnabled says whether anything can be sent at all. Without it the
	// draft is still readable — it is what would be sent — and says so.
	SMTPEnabled bool

	LLMViewURL  string
	HTMLViewURL string
}

// draftListData is passed to the draft listing templates.
type draftListData struct {
	Drafts      []draft
	LLMViewURL  string
	HTMLViewURL string
}

// describedBlock is one block prepared for display.
//
// It exists because a quote is attributed differently on screen and on the
// wire. The stored attribution is what gets sent, and names the sender the way
// they named themselves; the reader is better served by the name they assigned,
// so the display resolves the address again. Keeping both here means the
// template cannot accidentally show one where the other belongs.
type describedBlock struct {
	draftBlock
	// QuotedFrom is the resolved name of whoever wrote a quoted passage, for
	// display only. Zero for blocks that are not quotes or whose origin was not
	// recorded, in which case the stored attribution line is shown as it is.
	QuotedFrom mailtext.Name
	// HasQuotedFrom says whether QuotedFrom was resolved, since a zero Name and
	// a name that resolved to nothing look alike in a template.
	HasQuotedFrom bool
}

// describeBlocks prepares a draft's blocks for display.
func describeBlocks(d *draft, petnames map[string]string) []describedBlock {
	out := make([]describedBlock, 0, len(d.Blocks))
	for _, b := range d.Blocks {
		db := describedBlock{draftBlock: b}
		if b.Kind == blockQuote && b.Meta.QuoteFrom != "" {
			db.QuotedFrom = resolveAddress(petnames, b.Meta.QuoteFrom, b.Meta.QuoteName)
			db.HasQuotedFrom = true
		}
		out = append(out, db)
	}
	return out
}

// describedSet is one send option, with its recipients resolved to names.
//
// The labels are built here rather than in a template because the same words
// have to appear in the HTML button and in the text rendering: what a person
// reads before pressing send and what a machine reads before recommending it
// must be the same sentence.
type describedSet struct {
	Name string
	// Label is what the button says, e.g. "everyone on the thread".
	Label string
	// Explain is the consequence, e.g. "every subscriber of the list".
	Explain string
	// To and Cc are the recipients, resolved for display.
	To []mailtext.Name
	Cc []mailtext.Name
	// Count is how many addresses will receive the mail.
	Count int
	// NoReply marks a set whose only recipient looks like an address that does
	// not accept mail, so the interface can say so before it is used.
	NoReply bool
}

// setLabels gives each named set the words used to describe it.
var setLabels = map[string][2]string{
	setSender: {"the sender", "replies only to whoever wrote the message"},
	setAll:    {"everyone on the thread", "the sender and everyone in To and Cc"},
	setList:   {"the mailing list", "every subscriber of the list; your reply is public"},
}

// setOrder is the order the options are offered in: narrowest first.
//
// Deliberate rather than incidental. The list of recipients grows down the
// page, so the option that writes to the most people is never the one nearest
// the cursor, and nothing here is preselected.
var setOrder = []string{setSender, setAll, setList}

// describeSets renders a draft's recipient sets for display.
func describeSets(d *draft, petnames map[string]string) []describedSet {
	var out []describedSet
	for _, name := range setOrder {
		set, ok := d.Recipients[name]
		if !ok || set.Empty() {
			continue
		}
		labels := setLabels[name]
		ds := describedSet{
			Name:    name,
			Label:   labels[0],
			Explain: labels[1],
			Count:   set.Count(),
			To:      namesOf(set.To, petnames),
			Cc:      namesOf(set.Cc, petnames),
		}
		ds.NoReply = looksUnreplyable(set)
		out = append(out, ds)
	}
	return out
}

// namesOf resolves recipients for display, so that a send button names people
// the same way every other page does.
func namesOf(rs []Recipient, petnames map[string]string) []mailtext.Name {
	out := make([]mailtext.Name, 0, len(rs))
	for _, r := range rs {
		out = append(out, resolveAddress(petnames, r.Address(), ""))
	}
	return out
}

// looksUnreplyable reports whether a set writes only to addresses that
// conventionally discard what they receive.
//
// It is a guess about a naming convention and nothing more, which is why it
// only ever adds a warning beside a button and never removes the button: plenty
// of noreply@ addresses are read by somebody, and mailweb has no way to know.
// What it prevents is the case where a reply is composed with care and sent
// into a void that never says so.
func looksUnreplyable(set recipientSet) bool {
	if len(set.To) == 0 || len(set.Cc) > 0 {
		return false
	}
	for _, r := range set.To {
		local, _, _ := strings.Cut(r.Address(), "@")
		local = strings.ToLower(strings.ReplaceAll(local, "-", ""))
		local = strings.ReplaceAll(local, ".", "")
		local = strings.ReplaceAll(local, "_", "")
		if !strings.Contains(local, "noreply") && !strings.Contains(local, "donotreply") {
			return false
		}
	}
	return true
}