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
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/tls"
	"encoding/hex"
	"fmt"
	"html/template"
	"mime"
	qp "mime/quotedprintable"
	"net/smtp"
	"os"
	"strconv"
	"strings"
	"time"
)

// Notifications
// ============================================================================
//
// The mail carries the metadata and the capability link, never the audio. That
// keeps the message small enough to always deliver, and means the recording
// exists in exactly one place that can be deleted — a mailbox copy would
// outlive any deletion and defeat the point of being able to withdraw one.
//
// The link in the message is the only record of a submission's existence:
// there is no listing route (see review.go), so losing the mail means losing
// access to that recording. That is the intended trade.

// mailer sends notifications. A zero host disables sending, which is what
// makes the service runnable in a test without a mail server.
type mailer struct {
	host string
	port int
	user string
	pass string
	from string
	to   string
}

// loadMailer reads the whole mail configuration from one file.
//
// Host, username and recipient travel with the password rather than as
// command line flags, because flags end up in two places that are readable by
// anyone on the machine: the process table, and — for a service defined in
// Nix — the world-readable store path of the unit. None of these are as
// sensitive as the password, but they are the identity this thing sends as,
// and there is no reason to publish them to get a mail delivered.
//
// The format is one `key = value` per line, `#` comments and blank lines
// ignored:
//
//	host = smtp.example.org
//	port = 465
//	user = someone@example.org
//	pass = hunter2
//	from = someone@example.org
//	to   = someone@example.org
func loadMailer(path string) (*mailer, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	m := &mailer{port: 465}
	for i, line := range strings.Split(string(b), "\n") {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		key, value, ok := strings.Cut(line, "=")
		if !ok {
			return nil, fmt.Errorf("%s:%d: expected `key = value`", path, i+1)
		}
		key = strings.TrimSpace(key)
		value = strings.TrimSpace(value)
		switch key {
		case "host":
			m.host = value
		case "user":
			m.user = value
		case "pass":
			m.pass = value
		case "from":
			m.from = value
		case "to":
			m.to = value
		case "port":
			p, err := strconv.Atoi(value)
			if err != nil {
				return nil, fmt.Errorf("%s:%d: port %q is not a number", path, i+1, value)
			}
			m.port = p
		default:
			return nil, fmt.Errorf("%s:%d: unknown key %q", path, i+1, key)
		}
	}
	if m.host == "" {
		return nil, fmt.Errorf("%s: no host", path)
	}
	if m.to == "" {
		return nil, fmt.Errorf("%s: no recipient (to)", path)
	}
	if m.from == "" {
		m.from = m.user
	}
	if m.from == "" {
		return nil, fmt.Errorf("%s: no sender (from or user)", path)
	}
	return m, nil
}

func (m *mailer) enabled() bool { return m != nil && m.host != "" }

// notify composes and sends one notification.
func (m *mailer) notify(subject, text, html string) error {
	if !m.enabled() {
		return nil
	}
	msg := m.compose(subject, text, html)
	addr := fmt.Sprintf("%s:%d", m.host, m.port)

	// Implicit TLS (port 465). The connection is encrypted from the first
	// byte, so unlike STARTTLS there is no cleartext phase a downgrade could
	// keep it in.
	conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: m.host})
	if err != nil {
		return fmt.Errorf("connect to %s: %w", addr, err)
	}
	defer conn.Close()

	c, err := smtp.NewClient(conn, m.host)
	if err != nil {
		return fmt.Errorf("smtp handshake with %s: %w", addr, err)
	}
	defer c.Quit()

	if m.user != "" {
		if err := c.Auth(smtp.PlainAuth("", m.user, m.pass, m.host)); err != nil {
			return fmt.Errorf("smtp auth: %w", err)
		}
	}
	if err := c.Mail(m.from); err != nil {
		return fmt.Errorf("smtp MAIL FROM: %w", err)
	}
	if err := c.Rcpt(m.to); err != nil {
		return fmt.Errorf("smtp RCPT TO: %w", err)
	}
	wc, err := c.Data()
	if err != nil {
		return fmt.Errorf("smtp DATA: %w", err)
	}
	if _, err := wc.Write([]byte(msg)); err != nil {
		wc.Close()
		return fmt.Errorf("smtp write: %w", err)
	}
	if err := wc.Close(); err != nil {
		return fmt.Errorf("smtp close: %w", err)
	}
	return nil
}

// compose builds the message as multipart/alternative: the same notification
// twice, once as text and once as HTML.
//
// Both parts are sent rather than only the HTML one. A text part costs a few
// hundred bytes, is what a terminal client or a `less` over the mail spool
// shows, and is the version that still says everything if the HTML is stripped
// by a filter. The HTML part exists because the useful thing in this mail is a
// link and a transcript: a capability URL is 43 characters of base64 that no
// client linkifies reliably, and a transcript is a wall of prose that wants a
// margin.
//
// What the HTML part deliberately does not have is anything that loads from
// the network — no images, no stylesheets, no fonts. Styling is inline. A
// notification about a private recording should not be able to tell anyone
// that it has been read.
func (m *mailer) compose(subject, text, html string) string {
	var b strings.Builder
	fmt.Fprintf(&b, "From: %s\r\n", m.from)
	fmt.Fprintf(&b, "To: %s\r\n", m.to)
	fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
	fmt.Fprintf(&b, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
	b.WriteString("MIME-Version: 1.0\r\n")
	// The message contains a capability URL, so ask that it not be
	// auto-fetched by link scanners that honour this.
	b.WriteString("X-Auto-Response-Suppress: All\r\n")
	b.WriteString("Auto-Submitted: auto-generated\r\n")

	if html == "" {
		b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
		b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
		b.WriteString("\r\n")
		b.WriteString(quotedPrintable(text))
		b.WriteString("\r\n")
		return b.String()
	}

	boundary := mailBoundary(text, html)
	fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n", boundary)
	b.WriteString("\r\n")
	// Least-preferred part first: a client picks the last one it understands,
	// so text has to precede HTML for the HTML to be the one shown.
	b.WriteString("This is a message in MIME format.\r\n")

	// Quoted-printable for both parts, not 8bit. SMTP limits a line to 998
	// octets, and a transcript is one long unbroken paragraph: a few minutes
	// of speech is comfortably over that on a single line. An over-long line
	// is not a theoretical problem — a server may refuse the message outright
	// or wrap it at a column of its choosing, which in the HTML part can land
	// inside a tag and corrupt the markup. quoted-printable soft-wraps at 76
	// and the client joins the lines back up, so what is read is what was
	// written.
	fmt.Fprintf(&b, "\r\n--%s\r\n", boundary)
	b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
	b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
	b.WriteString("\r\n")
	b.WriteString(quotedPrintable(text))

	fmt.Fprintf(&b, "\r\n--%s\r\n", boundary)
	b.WriteString("Content-Type: text/html; charset=utf-8\r\n")
	b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
	b.WriteString("\r\n")
	b.WriteString(quotedPrintable(html))

	fmt.Fprintf(&b, "\r\n--%s--\r\n", boundary)
	return b.String()
}

// toCRLF normalises line endings. A bare LF inside a body would break the DATA
// sequence for a strict server.
func toCRLF(s string) string {
	return strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\n", "\r\n")
}

// mailBoundary returns a delimiter that appears in neither part.
//
// It is random rather than fixed because a boundary that occurs inside a part
// ends that part early, and the parts here contain text written by a stranger:
// a fixed string could be reproduced deliberately in a note to truncate the
// message or to smuggle a part of their own into it. 128 bits of randomness
// makes that impossible to do on purpose, and the loop makes it impossible to
// do by accident.
func mailBoundary(parts ...string) string {
	for {
		var buf [16]byte
		if _, err := rand.Read(buf[:]); err != nil {
			// Failing closed here would mean not sending the notification at
			// all, which loses the only record of the submission. The time is
			// not secret but it is not something a submitter can influence
			// with any precision either.
			return fmt.Sprintf("=_observations_%d_=", time.Now().UnixNano())
		}
		b := "=_observations_" + hex.EncodeToString(buf[:]) + "_="
		clash := false
		for _, p := range parts {
			if strings.Contains(p, b) {
				clash = true
				break
			}
		}
		if !clash {
			return b
		}
	}
}

// quotedPrintable encodes a body for transport, which is what keeps a long
// line from being wrapped by a server at a column of its choosing.
func quotedPrintable(s string) string {
	var buf bytes.Buffer
	w := qp.NewWriter(&buf)
	// Writing to a bytes.Buffer cannot fail, and neither can closing one.
	w.Write([]byte(toCRLF(s)))
	w.Close()
	return buf.String()
}

// submissionMail is the notification for a new submission, in both forms.
func submissionMail(baseURL string, s *Submission, used int64, transcriptErr error) (subject, text, html string) {
	subject = fmt.Sprintf("observations: a submission (%s)", humanBytes(s.ByteSize))

	var b strings.Builder
	b.WriteString("Someone sent in an observation.\n\n")
	fmt.Fprintf(&b, "  size       %s\n", humanBytes(s.ByteSize))
	if d := durationText(s); d != "" {
		fmt.Fprintf(&b, "  duration   %s\n", d)
	}
	fmt.Fprintf(&b, "  type       %s\n", s.MIME)
	if s.Filename != "" {
		fmt.Fprintf(&b, "  filename   %s\n", s.Filename)
	}
	fmt.Fprintf(&b, "  received   %s\n", s.ReceivedAt.Format("2006-01-02 15:04"))
	fmt.Fprintf(&b, "  inbox      %s of %s used\n", humanBytes(used), humanBytes(quotaBytes))

	if s.Note != "" {
		fmt.Fprintf(&b, "\nThey wrote:\n\n%s\n", indent(s.Note, "  "))
	}

	fmt.Fprintf(&b, "\nListen, and delete it when you are done:\n\n  %s/inbox/%s\n",
		strings.TrimRight(baseURL, "/"), s.Token)
	b.WriteString("\nThat link is the only way to reach this recording: there is no\n" +
		"listing, so keep the mail until you have dealt with it.\n")

	switch {
	case !s.Consented:
		b.WriteString("\nThe sender did not consent to transcription, so there is no\n" +
			"transcript and the audio was not sent anywhere.\n")
	case transcriptErr != nil:
		fmt.Fprintf(&b, "\nTranscription failed (%s). The recording is unaffected.\n", transcriptErr)
	case s.Transcript.String != "":
		fmt.Fprintf(&b, "\nTranscript:\n\n%s\n", indent(s.Transcript.String, "  "))
	}

	link := fmt.Sprintf("%s/inbox/%s", strings.TrimRight(baseURL, "/"), s.Token)
	var hb bytes.Buffer
	if err := submissionHTML.Execute(&hb, map[string]any{
		"Link":       link,
		"Size":       humanBytes(s.ByteSize),
		"Duration":   durationText(s),
		"MIME":       s.MIME,
		"Filename":   s.Filename,
		"Received":   s.ReceivedAt.Format("2006-01-02 15:04"),
		"Used":       humanBytes(used),
		"Quota":      humanBytes(quotaBytes),
		"Note":       s.Note,
		"Consented":  s.Consented,
		"TranscErr":  errText(transcriptErr),
		"Transcript": s.Transcript.String,
	}); err != nil {
		// The text part says everything; a broken template is not a reason to
		// withhold the only record of a submission.
		return subject, b.String(), ""
	}
	return subject, b.String(), hb.String()
}

// quotaFullMail warns that the inbox has stopped accepting.
func quotaFullMail(used int64) (subject, text, html string) {
	subject = "observations: the inbox is full"
	text = fmt.Sprintf(
		"The submission inbox has reached its limit and is refusing new\n"+
			"submissions until something is deleted.\n\n"+
			"  used   %s\n  limit  %s\n\n"+
			"The limit exists to keep this from filling the disk the rest of the\n"+
			"machine runs on. Delete a submission from its link to make room.\n",
		humanBytes(used), humanBytes(quotaBytes))

	var hb bytes.Buffer
	if err := quotaFullHTML.Execute(&hb, map[string]any{
		"Used":  humanBytes(used),
		"Quota": humanBytes(quotaBytes),
	}); err != nil {
		return subject, text, ""
	}
	return subject, text, hb.String()
}

func errText(err error) string {
	if err == nil {
		return ""
	}
	return err.Error()
}

// The HTML parts.
//
// html/template rather than string concatenation, and that is load-bearing
// rather than tidiness: the note and the transcript are written by whoever sent
// the recording, and a note containing markup would otherwise be markup in the
// mail. Every interpolation below is escaped by the template.
//
// Styling is inline and there are no remote resources of any kind. A mail
// about a private recording must not be able to report that it was opened.
const mailStyle = `font-family:system-ui,-apple-system,Segoe UI,sans-serif;` +
	`font-size:15px;line-height:1.5;color:#111;max-width:40em`

var submissionHTML = template.Must(template.New("submission").Parse(
	`<!DOCTYPE html>
<html><body style="` + mailStyle + `">
<p>Someone sent in an observation.</p>

<table cellpadding="0" cellspacing="0" style="border-collapse:collapse;font-size:14px">
  <tr><td style="padding:2px 12px 2px 0;color:#666">size</td><td>{{ .Size }}</td></tr>
  {{ with .Duration }}<tr><td style="padding:2px 12px 2px 0;color:#666">duration</td><td>{{ . }}</td></tr>{{ end }}
  <tr><td style="padding:2px 12px 2px 0;color:#666">type</td><td>{{ .MIME }}</td></tr>
  {{ with .Filename }}<tr><td style="padding:2px 12px 2px 0;color:#666">filename</td><td>{{ . }}</td></tr>{{ end }}
  <tr><td style="padding:2px 12px 2px 0;color:#666">received</td><td>{{ .Received }}</td></tr>
  <tr><td style="padding:2px 12px 2px 0;color:#666">inbox</td><td>{{ .Used }} of {{ .Quota }} used</td></tr>
</table>

{{ with .Note }}
<p style="color:#666;margin-bottom:4px">They wrote:</p>
<blockquote style="margin:0;padding:8px 12px;border-left:3px solid #ddd;font-style:italic;white-space:pre-wrap">{{ . }}</blockquote>
{{ end }}

<p style="margin-top:20px"><a href="{{ .Link }}" style="font-weight:600">Listen, and delete it when you are done</a></p>

<p style="color:#666;font-size:13px">That link is the only way to reach this recording:
there is no listing, so keep the mail until you have dealt with it.</p>

{{ if not .Consented }}
<p style="color:#666;font-size:13px">The sender did not consent to transcription, so
there is no transcript and the audio was not sent anywhere.</p>
{{ else if .TranscErr }}
<p style="color:#666;font-size:13px">Transcription failed ({{ .TranscErr }}).
The recording is unaffected.</p>
{{ else if .Transcript }}
<p style="color:#666;margin-bottom:4px">Transcript:</p>
<div style="padding:12px;background:#f4f4f4;border-radius:4px;white-space:pre-wrap">{{ .Transcript }}</div>
{{ end }}
</body></html>
`))

var quotaFullHTML = template.Must(template.New("quotafull").Parse(
	`<!DOCTYPE html>
<html><body style="` + mailStyle + `">
<p>The submission inbox has reached its limit and is refusing new submissions
until something is deleted.</p>

<table cellpadding="0" cellspacing="0" style="border-collapse:collapse;font-size:14px">
  <tr><td style="padding:2px 12px 2px 0;color:#666">used</td><td>{{ .Used }}</td></tr>
  <tr><td style="padding:2px 12px 2px 0;color:#666">limit</td><td>{{ .Quota }}</td></tr>
</table>

<p style="color:#666;font-size:13px">The limit exists to keep this from filling the
disk the rest of the machine runs on. Delete a submission from its link to make room.</p>
</body></html>
`))

func indent(s, prefix string) string {
	lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
	for i, l := range lines {
		lines[i] = prefix + l
	}
	return strings.Join(lines, "\n")
}