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
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
package main

// iCalendar, enough of it to say what an attached event is.
//
// 127 messages on this account carry an .ics. Until now the attachment list
// named the file and stopped there, so the answer to "when is this meeting" was
// a download and an external application — and one of those files, a cancelled
// carsharing booking, was indistinguishable in the interface from a live one.
//
// This reads the handful of fields that answer that question. It is not a
// calendar implementation and is not trying to become one: no recurrence
// expansion, no free/busy, no VTODO, no timezone conversion. Those need to be
// right rather than approximately right, and being confidently wrong about when
// a meeting is, is worse than saying nothing — see describeRecurrence.
//
// # Why a component stack rather than a line filter
//
// The obvious implementation greps the lines for SUMMARY, DTSTART and friends.
// It is wrong on essentially every file in this archive, in two ways that do
// not announce themselves:
//
//   - Every file with a VTIMEZONE carries RRULEs describing daylight-saving
//     transitions, in this archive going back to 1893. A line filter reads
//     those as the event's own recurrence and reports a one-off meeting as
//     "repeats yearly on the 3rd Monday of September".
//
//   - VALARM components nest *inside* VEVENT (22 of them here), carrying their
//     own TRIGGER and ACTION. Flattened, an alarm's fields become the event's.
//
// So parsing tracks which component each line belongs to, and reads properties
// only from the VEVENT level. Both bugs then cannot be written; neither is
// visible once prevented, which is why this comment exists.

import (
	"fmt"
	"net/url"
	"strings"
	"time"

	// The zone database is compiled in rather than read from the host. mailweb
	// renders an event in the timezone its sender named, so a missing
	// /usr/share/zoneinfo would silently change what times are displayed. This
	// costs ~450KB and makes the binary say the same thing everywhere.
	_ "time/tzdata"
)

// Limits. An iCalendar file is a text document from a stranger, and these bound
// what one can cost before it is read. The largest in this archive is 7KB with
// one event; anything approaching these numbers is not a calendar.
const (
	maxICalBytes    = 256 << 10 // refuse to parse beyond this
	maxICalEvents   = 32        // stop collecting after this many VEVENTs
	maxICalNesting  = 8         // BEGIN: depth before the file is judged hostile
	maxICalTextRune = 4096      // truncate any single displayed value
)

// icalEvent is one VEVENT, reduced to what a summary shows.
type icalEvent struct {
	Summary  string
	Location string
	// Start and End are the parsed timestamps. Zero when absent or unparseable,
	// which is why StartText carries the rendered form: a file whose DTSTART
	// this cannot read still has one, and printing nothing would be a claim
	// that it does not.
	Start, End time.Time
	// TZLabel names the basis of the times: an IANA zone, "UTC", or "floating".
	// mailweb never converts, so this is what makes a rendered time meaningful
	// rather than merely plausible. See formatICalTime.
	TZLabel string
	// AllDay marks a DATE-valued DTSTART, where the time of day is not a fact
	// about the event. None occur in this archive; the flag exists so that one
	// is not rendered as midnight.
	AllDay bool

	// Cancelled is set by STATUS:CANCELLED on the event or METHOD:CANCEL on the
	// calendar. It is the one field here whose absence would make mailweb state
	// something false: a cancelled booking rendered plainly reads as a live one.
	Cancelled bool
	// Recurring says the event repeats. mailweb does not expand it — see
	// describeRecurrence — so this exists to warn rather than to schedule.
	Recurring bool

	// GeoURI is a maps link built from GEO, when the sender gave coordinates.
	// Preferred over the location string, which is a search rather than a place.
	GeoLat, GeoLon string

	// Description is the free text, and DescriptionIsHTML says how to render it.
	// DescriptionSniffed says that judgement was mailweb's rather than the
	// sender's; see pickDescription.
	Description        string
	DescriptionIsHTML  bool
	DescriptionSniffed bool
}

// icalCalendar is a parsed file: its events, and the properties that belong to
// the calendar rather than to any one of them.
type icalCalendar struct {
	Method string
	Events []icalEvent
}

// parseICal reads an iCalendar file into the events it describes.
//
// It never returns an error for malformed input, and this is deliberate: the
// input is a document from a stranger attached to unsolicited mail, and there
// is nothing a reader can do with "line 47 is invalid". Whatever can be read is
// returned; the rest is dropped. A file yielding no events renders as no
// summary, which is the same as not having tried.
func parseICal(data []byte) icalCalendar {
	var cal icalCalendar
	if len(data) == 0 || len(data) > maxICalBytes {
		return cal
	}

	// A component stack, not a boolean "in an event": VALARM nests inside
	// VEVENT, and VTIMEZONE's STANDARD and DAYLIGHT nest inside it. Properties
	// are read only when VEVENT is the innermost component, which is what keeps
	// an alarm's TRIGGER and a timezone's RRULE out of the event.
	var stack []string
	var cur *icalEvent

	for _, line := range unfoldICal(string(data)) {
		name, params, value := splitICalLine(line)
		if name == "" {
			continue
		}

		switch strings.ToUpper(name) {
		case "BEGIN":
			if len(stack) >= maxICalNesting {
				return cal // absurdly nested: stop rather than walk it
			}
			comp := strings.ToUpper(value)
			stack = append(stack, comp)
			if comp == "VEVENT" && len(cal.Events) < maxICalEvents {
				cal.Events = append(cal.Events, icalEvent{})
				cur = &cal.Events[len(cal.Events)-1]
			}
			continue
		case "END":
			if len(stack) > 0 {
				stack = stack[:len(stack)-1]
			}
			if strings.EqualFold(value, "VEVENT") {
				cur = nil
			}
			continue
		}

		// Calendar-level properties. METHOD:CANCEL is how several senders mark
		// a withdrawal, in place of or alongside STATUS on the event.
		if len(stack) == 1 && stack[0] == "VCALENDAR" {
			if strings.EqualFold(name, "METHOD") {
				cal.Method = strings.ToUpper(value)
			}
			continue
		}

		// Only the event's own properties. Anything deeper is an alarm, and
		// anything in a VTIMEZONE is a rule about clocks, not about this event.
		if cur == nil || len(stack) == 0 || stack[len(stack)-1] != "VEVENT" {
			continue
		}
		applyEventProperty(cur, strings.ToUpper(name), params, value)
	}

	// METHOD:CANCEL cancels every event the file carries.
	if cal.Method == "CANCEL" {
		for i := range cal.Events {
			cal.Events[i].Cancelled = true
		}
	}
	// Both descriptions have been seen by now, so the choice between them —
	// and the guess about an undeclared one — can be made once per event.
	for i := range cal.Events {
		cal.Events[i].resolveDescription()
	}
	return cal
}

// applyEventProperty records one property of a VEVENT.
func applyEventProperty(ev *icalEvent, name string, params map[string]string, value string) {
	switch name {
	case "SUMMARY":
		ev.Summary = truncateRunes(unescapeICalText(value), maxICalTextRune)
	case "LOCATION":
		ev.Location = truncateRunes(unescapeICalText(value), maxICalTextRune)
	case "STATUS":
		if strings.EqualFold(value, "CANCELLED") {
			ev.Cancelled = true
		}
	case "DTSTART":
		ev.Start, ev.TZLabel, ev.AllDay = parseICalTime(params, value)
	case "DTEND":
		ev.End, _, _ = parseICalTime(params, value)
	case "GEO":
		// "lat;lon", both decimal. Kept as strings: this is passed to a URL
		// builder, not arithmetic, and reformatting a float can only lose.
		if lat, lon, ok := strings.Cut(value, ";"); ok {
			ev.GeoLat, ev.GeoLon = strings.TrimSpace(lat), strings.TrimSpace(lon)
		}
	case "RRULE", "RDATE", "RECURRENCE-ID":
		// Reached only at VEVENT level, which is the whole point of the stack:
		// the identical property inside VTIMEZONE describes daylight saving.
		ev.Recurring = true
	case "DESCRIPTION":
		// The plain description. Kept even when an HTML twin exists, since
		// pickDescription decides between them once both have been seen.
		if ev.Description == "" || !ev.DescriptionIsHTML {
			ev.Description = truncateRunes(unescapeICalText(value), maxICalTextRune)
			ev.DescriptionIsHTML = false
		}
	case "X-ALT-DESC":
		// Outlook's HTML twin of DESCRIPTION, and the only place in this format
		// where the content type is actually declared.
		if strings.EqualFold(params["FMTTYPE"], "text/html") {
			ev.Description = truncateRunes(unescapeICalText(value), maxICalTextRune)
			ev.DescriptionIsHTML = true
		}
	}
}

// unfoldICal splits an iCalendar file into logical lines.
//
// RFC 5545 folds long lines by inserting CRLF and a single space or tab, and
// senders fold mid-word and mid-URL: 58 files in this archive do, and one of
// them splits a check-in link across four lines. Unfolding therefore has to
// happen before anything else looks at the content, and the leading whitespace
// is removed rather than preserved — it is an artefact of the encoding, not
// part of the value.
func unfoldICal(s string) []string {
	s = strings.ReplaceAll(s, "\r\n", "\n")
	s = strings.ReplaceAll(s, "\r", "\n")
	raw := strings.Split(s, "\n")

	out := make([]string, 0, len(raw))
	for _, line := range raw {
		if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 {
			out[len(out)-1] += line[1:]
			continue
		}
		out = append(out, line)
	}
	return out
}

// splitICalLine breaks "NAME;PARAM=VALUE:VALUE" into its three parts.
//
// The split is at the first colon *outside* a quoted string, because parameter
// values are quoted precisely when they contain the delimiters — this archive
// has ATTENDEE;CN="Félix Baylac Jacqué" and senders who put URLs, which contain
// colons, in parameters. Splitting at the first colon found would truncate the
// name and lose the value.
func splitICalLine(line string) (name string, params map[string]string, value string) {
	inQuote := false
	colon := -1
	semi := -1
	for i := 0; i < len(line); i++ {
		switch line[i] {
		case '"':
			inQuote = !inQuote
		case ':':
			if !inQuote {
				colon = i
			}
		case ';':
			if !inQuote && semi < 0 {
				semi = i
			}
		}
		if colon >= 0 {
			break
		}
	}
	if colon < 0 {
		return "", nil, ""
	}
	value = line[colon+1:]

	head := line[:colon]
	if semi < 0 || semi > colon {
		return strings.TrimSpace(head), nil, value
	}
	name = strings.TrimSpace(head[:semi])
	params = parseICalParams(head[semi+1:])
	return name, params, value
}

// parseICalParams reads the ";KEY=VALUE;KEY=VALUE" tail of a property name.
// Keys are upper-cased so lookups need not guess at the sender's capitalisation;
// values keep their case, since TZID names are case-sensitive to the zone
// database.
func parseICalParams(s string) map[string]string {
	params := make(map[string]string)
	for _, part := range splitUnquoted(s, ';') {
		k, v, ok := strings.Cut(part, "=")
		if !ok {
			continue
		}
		params[strings.ToUpper(strings.TrimSpace(k))] =
			strings.Trim(strings.TrimSpace(v), `"`)
	}
	return params
}

// splitUnquoted splits on a separator appearing outside double quotes.
func splitUnquoted(s string, sep byte) []string {
	var out []string
	inQuote := false
	start := 0
	for i := 0; i < len(s); i++ {
		switch s[i] {
		case '"':
			inQuote = !inQuote
		case sep:
			if !inQuote {
				out = append(out, s[start:i])
				start = i + 1
			}
		}
	}
	return append(out, s[start:])
}

// unescapeICalText reverses the escaping RFC 5545 applies to text values.
// A literal backslash is handled by consuming pairs left to right, so "\\n" is
// a backslash followed by an n rather than a newline.
func unescapeICalText(s string) string {
	if !strings.ContainsRune(s, '\\') {
		return s
	}
	var b strings.Builder
	b.Grow(len(s))
	for i := 0; i < len(s); i++ {
		if s[i] != '\\' || i+1 >= len(s) {
			b.WriteByte(s[i])
			continue
		}
		i++
		switch s[i] {
		case 'n', 'N':
			b.WriteByte('\n')
		case ',', ';', '\\':
			b.WriteByte(s[i])
		default:
			// An escape this does not know is passed through with its
			// backslash: guessing would corrupt a value that was never escaped.
			b.WriteByte('\\')
			b.WriteByte(s[i])
		}
	}
	return b.String()
}

// parseICalTime reads a DTSTART or DTEND into a timestamp and the label naming
// what the timestamp means.
//
// Three forms occur, and mailweb renders all three in the sender's own terms
// rather than converting:
//
//   - "…Z" is UTC (76 of the values in this archive).
//   - TZID=Europe/Berlin names an IANA zone (10 here). Resolved through the
//     compiled-in zone database; an unknown name keeps the wall time and is
//     labelled with the name as given, since a zone mailweb cannot resolve is
//     still information the reader may recognise.
//   - Neither is a floating time (57 here): wall time wherever the reader is,
//     which is a real thing to be rather than a missing timezone.
//
// See formatICalTime for why none of these is converted to local time.
func parseICalTime(params map[string]string, value string) (t time.Time, label string, allDay bool) {
	value = strings.TrimSpace(value)

	// A DATE value has no time of day. None occur in this archive; rendering
	// one as midnight would invent a precision the sender did not state.
	if strings.EqualFold(params["VALUE"], "DATE") || len(value) == 8 {
		if d, err := time.Parse("20060102", value); err == nil {
			return d, "", true
		}
		return time.Time{}, "", true
	}

	if strings.HasSuffix(value, "Z") {
		if u, err := time.Parse("20060102T150405Z", value); err == nil {
			return u, "UTC", false
		}
		return time.Time{}, "UTC", false
	}

	if tzid := params["TZID"]; tzid != "" {
		if loc, err := time.LoadLocation(tzid); err == nil {
			if lt, err := time.ParseInLocation("20060102T150405", value, loc); err == nil {
				return lt, tzid, false
			}
		}
		// Unresolvable zone: keep the wall time, report the name as given.
		if wt, err := time.Parse("20060102T150405", value); err == nil {
			return wt, tzid, false
		}
		return time.Time{}, tzid, false
	}

	if wt, err := time.Parse("20060102T150405", value); err == nil {
		return wt, "floating", false
	}
	return time.Time{}, "", false
}

// FormatWhen renders the event's time span the way a reader reads it.
//
// The times are shown in the zone the sender named, labelled with that zone,
// and are never converted to the reader's own. A conversion would need the
// event's zone, the reader's zone and a current zone database all to be right;
// getting any of them wrong produces a confidently displayed wrong hour, which
// is exactly the failure that makes a calendar useless. Labelling instead makes
// the basis explicit and leaves the arithmetic to a reader who can see it.
func (e icalEvent) FormatWhen() string {
	if e.Start.IsZero() {
		return ""
	}
	if e.AllDay {
		if !e.End.IsZero() && e.End.After(e.Start) {
			return e.Start.Format("Mon 2 Jan 2006") + " – " + e.End.Format("Mon 2 Jan 2006") + " (all day)"
		}
		return e.Start.Format("Mon 2 Jan 2006") + " (all day)"
	}

	out := e.Start.Format("Mon 2 Jan 2006, 15:04")
	if !e.End.IsZero() {
		if sameICalDay(e.Start, e.End) {
			out += "–" + e.End.Format("15:04")
		} else {
			out += " – " + e.End.Format("Mon 2 Jan 2006, 15:04")
		}
	}
	if e.TZLabel != "" {
		out += " (" + e.TZLabel + ")"
	}
	return out
}

// Relative says how far off the event is: "in 3 days", "yesterday", "3 months
// ago". Empty when there is no start to measure from.
//
// This is what most calendar mail is actually read for. An absolute date
// answers "when", but the question in front of a booking confirmation is
// usually "is this still ahead of me" — and a date alone makes the reader do
// the arithmetic against today, which is exactly the sort of thing a machine
// should have done for them.
//
// It is deliberately coarse. Past a few days nobody needs "in 17 days" to the
// hour, and a rounded figure cannot be wrong in the way a precise one can: the
// times themselves are never converted between zones (see FormatWhen), so a
// distance computed across zones is approximate by construction and is phrased
// so as not to claim otherwise.
//
// Computed against the wall clock at render time, which is why it is a method
// rather than a stored field.
func (e icalEvent) Relative() string {
	if e.Start.IsZero() {
		return ""
	}
	return relativeTime(e.Start, time.Now())
}

// IsPast says the event has already happened, so a view can mark it as spent
// rather than upcoming. An event with no readable start is not past: absent is
// not the same as over.
func (e icalEvent) IsPast() bool {
	return !e.Start.IsZero() && e.Start.Before(time.Now())
}

// relativeTime renders the distance between two instants in words.
//
// Split from Relative so the wording can be tested against a fixed "now" rather
// than against whenever the suite happens to run.
//
// Day distances are counted between calendar days rather than by dividing a
// duration: an event at 23:00 tonight and one at 01:00 tomorrow are three hours
// apart but are "today" and "tomorrow", which is what a reader means by those
// words. Dividing the duration would call both "in 0 days".
func relativeTime(t, now time.Time) string {
	// Compare calendar days in the event's own location, since that is the
	// clock the event is stated in.
	day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
	today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, t.Location())
	days := int(day.Sub(today).Hours() / 24)

	switch {
	case days == 0:
		// Within today, the hours are worth having: "in 2 hours" is a different
		// message from "9 hours ago", and both are "today".
		d := t.Sub(now)
		switch {
		case d > time.Hour:
			return fmt.Sprintf("in %d hours", int(d.Hours()))
		case d > time.Minute:
			return fmt.Sprintf("in %d minutes", int(d.Minutes()))
		case d > 0:
			return "in a moment"
		case d > -time.Hour:
			return fmt.Sprintf("%d minutes ago", int(-d.Minutes()))
		default:
			return fmt.Sprintf("%d hours ago", int(-d.Hours()))
		}
	case days == 1:
		return "tomorrow"
	case days == -1:
		return "yesterday"
	case days > 0:
		return "in " + coarseDuration(days)
	default:
		return coarseDuration(-days) + " ago"
	}
}

// coarseDuration renders a whole number of days in the largest unit that does
// not overstate the precision, carrying a second unit past a year.
//
// The second unit is there because a mail archive is mostly old: 83 of the 132
// calendar attachments here are more than a year back, and with years alone
// every one of them read "a year ago" — a single bucket holding most of the
// corpus, which is the least useful thing a relative date can say. "a year and
// 3 months ago" separates them at the resolution the reader actually wants.
//
// It stops at two units. Weeks are not added to months, and days not to weeks,
// because below a year the leading figure already carries the answer and a
// remainder would only add noise to a number that is approximate anyway — these
// are calendar-day counts across timezones that are deliberately never
// converted (see FormatWhen), so the last unit of precision was never real.
func coarseDuration(days int) string {
	switch {
	case days < 7:
		return fmt.Sprintf("%d days", days)
	case days < 31:
		if w := days / 7; w == 1 {
			return "a week"
		} else {
			return fmt.Sprintf("%d weeks", w)
		}
	case days < 365:
		if m := days / 30; m == 1 {
			return "a month"
		} else {
			return fmt.Sprintf("%d months", m)
		}
	default:
		years := days / 365
		months := (days % 365) / 30

		out := fmt.Sprintf("%d years", years)
		if years == 1 {
			out = "a year"
		}
		switch {
		case months == 0:
			return out
		case months == 1:
			return out + " and a month"
		default:
			return fmt.Sprintf("%s and %d months", out, months)
		}
	}
}

func sameICalDay(a, b time.Time) bool {
	ay, am, ad := a.Date()
	by, bm, bd := b.Date()
	return ay == by && am == bm && ad == bd
}

// LocationURL is where the location can be looked at, or "" for none.
//
// Three cases, in descending order of how much the sender actually told us:
// exact coordinates from GEO; a location that is itself a URL, which is a video
// call rather than a place and links to itself; and otherwise a map search for
// the text, which is a guess at what the string names and is offered as such.
//
// Every one of these is a link, never an embed. Nothing is requested until the
// reader clicks, so rendering an event sends nothing anywhere — which is the
// same property the img-src 'self' policy gives a message body.
func (e icalEvent) LocationURL() string {
	if e.GeoLat != "" && e.GeoLon != "" {
		return fmt.Sprintf(
			"https://www.openstreetmap.org/?mlat=%s&mlon=%s#map=17/%s/%s",
			url.QueryEscape(e.GeoLat), url.QueryEscape(e.GeoLon),
			url.QueryEscape(e.GeoLat), url.QueryEscape(e.GeoLon))
	}
	loc := strings.TrimSpace(e.Location)
	if loc == "" {
		return ""
	}
	if u, err := url.Parse(loc); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
		return loc
	}
	return "https://www.openstreetmap.org/search?query=" + url.QueryEscape(loc)
}

// LocationIsLink says the location is a URL rather than a place, so a view can
// call it what it is instead of offering a map of a video call.
func (e icalEvent) LocationIsLink() bool {
	u, err := url.Parse(strings.TrimSpace(e.Location))
	return err == nil && (u.Scheme == "http" || u.Scheme == "https")
}

// LocationIsSearch says the map link is a search for the location text rather
// than a place the sender actually pinned.
//
// The distinction is the point: with GEO the sender gave coordinates and the
// link goes exactly there, while without it mailweb is guessing that a string
// names somewhere findable — "Augsburg Hbf" resolves, "Zimmererstraße,
// Augsburg/Textilviertel" may not, and neither is mailweb's knowledge. A view
// marks the guess and leaves the pin unmarked, so the two do not read alike.
func (e icalEvent) LocationIsSearch() bool {
	if e.GeoLat != "" && e.GeoLon != "" {
		return false // an exact pin from the sender
	}
	return e.Location != "" && !e.LocationIsLink()
}

// RecurrenceWarning is what to say about a repeating event, or "" for one that
// does not repeat.
//
// mailweb deliberately does not expand recurrence. Doing it correctly means a
// full RRULE engine — FREQ, INTERVAL, BYDAY, BYSETPOS, COUNT, UNTIL, EXDATE —
// evaluated against a timezone database with daylight-saving transitions, and
// the failure mode of getting it subtly wrong is a confidently displayed date
// that no meeting happens on. A reader who is told mailweb is not the authority
// here loses nothing but a click; a reader given a wrong date misses the event.
//
// Nothing in this archive currently recurs, so this path is a guard rather than
// a feature: it exists because the parser can now tell a VEVENT's RRULE from a
// VTIMEZONE's, and the moment it could not, the warning would have fired on
// every file.
func (e icalEvent) RecurrenceWarning() string {
	if !e.Recurring {
		return ""
	}
	return "repeating event — only the first occurrence is shown; " +
		"open the .ics in a calendar application for the whole series"
}

// resolveDescription decides how the event's free text should be rendered, and
// records whether that decision was the sender's or mailweb's.
//
// The declared type is unreliable in both directions. X-ALT-DESC;FMTTYPE is the
// only place this format states a content type, and 36 files here use it
// honestly. DESCRIPTION never carries FMTTYPE at all — yet 32 files put real
// markup in it, including the check-in link that is the entire point of a
// Deutsche Bahn booking. Believing only the declaration would render those as
// visible angle brackets and leave the useful link unusable.
//
// So an undeclared description that looks like markup is treated as markup, and
// the view says that mailweb guessed. The guess costs no safety: the text is
// rendered in the same sandboxed, script-free frame a message body gets, so the
// worst outcome is prose containing angle brackets displayed as tags. What it
// must not do is pass silently, since a reader has no other way to know whether
// they are looking at what the sender wrote or at mailweb's reading of it.
func (e *icalEvent) resolveDescription() {
	if e.Description == "" || e.DescriptionIsHTML {
		return // absent, or the sender declared it: nothing to guess
	}
	if looksLikeHTML(e.Description) {
		e.DescriptionIsHTML = true
		e.DescriptionSniffed = true
	}
}

// looksLikeHTML reports whether an undeclared string carries markup.
//
// Deliberately narrow: it looks for the specific tags that actually appear in
// these descriptions rather than for anything angle-bracketed, so that prose
// like "a < b" or "<untitled>" is not promoted to markup on the strength of a
// stray bracket.
func looksLikeHTML(s string) bool {
	l := strings.ToLower(s)
	for _, tag := range []string{
		"<a ", "<a\t", "<a\n", "<br", "<p>", "<p ", "<div", "<span",
		"<html", "<body", "<table", "<ul", "<ol", "<li", "<b>", "<i>", "<strong",
	} {
		if strings.Contains(l, tag) {
			return true
		}
	}
	return false
}

// truncateRunes bounds a displayed value, cutting on a rune boundary so that a
// truncated UTF-8 string is still valid UTF-8.
func truncateRunes(s string, max int) string {
	if len(s) <= max {
		return s
	}
	r := []rune(s)
	if len(r) <= max {
		return s
	}
	return string(r[:max]) + "…"
}

// isCalendarPart says whether an attachment is worth trying to read as a
// calendar.
//
// Both the type and the name are consulted because senders get the type wrong
// in bulk: of the .ics files in this archive, 41 are labelled text/plain,
// application/octet-stream, text/x-vcalendar or application/ics rather than
// text/calendar. Selecting on the declared type alone would skip a third of
// them. Neither field is verified — both are written by the sender — so this
// decides only what is offered, and the parser decides what is actually there.
func isCalendarPart(a attachment) bool {
	switch strings.ToLower(a.MimeType) {
	case "text/calendar", "text/x-vcalendar", "application/ics":
		return true
	}
	return strings.HasSuffix(strings.ToLower(strings.TrimSpace(a.Filename)), ".ics")
}