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

// Date filtering.
//
// Paging alone answers "show me more"; it does not answer "show me today",
// which is the question actually asked of a mail index. Reaching a given day by
// paging means walking every message newer than it and knowing when to stop,
// so ?since= and ?until= name the range directly.
//
// Both bounds are resolved in the server's local time zone, which is the one
// the dates in a listing are rendered in: a client that reads "2026-08-16
// 14:40" in the output and asks for 2026-08-16 must get that message back.

import (
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// DateRange is a resolved half-open interval [Since, Until). Either end may be
// zero, meaning unbounded in that direction.
type DateRange struct {
	Since, Until time.Time
	sinceRaw     string
	untilRaw     string
}

// ParseDateRange reads the since and until parameters off the query string.
//
// Accepted forms are an absolute YYYY-MM-DD, the words today and yesterday, and
// a relative offset like -7d, -24h or -2w. The day-granular forms are widened
// to whole days at both ends — since=today starts at midnight, until=today ends
// at the following midnight — so that ?since=today&until=today is exactly
// today's mail rather than an empty interval, which is what a literal reading
// of the two identical timestamps would give.
func ParseDateRange(r *http.Request) (DateRange, error) {
	q := r.URL.Query()
	var d DateRange
	var err error
	if d.sinceRaw = q.Get("since"); d.sinceRaw != "" {
		if d.Since, err = parseDateBound(d.sinceRaw, false); err != nil {
			return DateRange{}, fmt.Errorf("since: %w", err)
		}
	}
	if d.untilRaw = q.Get("until"); d.untilRaw != "" {
		if d.Until, err = parseDateBound(d.untilRaw, true); err != nil {
			return DateRange{}, fmt.Errorf("until: %w", err)
		}
	}
	if !d.Since.IsZero() && !d.Until.IsZero() && d.Until.Before(d.Since) {
		return DateRange{}, fmt.Errorf(
			"until (%s) is before since (%s)", d.untilRaw, d.sinceRaw)
	}
	return d, nil
}

// parseDateBound resolves one bound. endOfDay widens a day-granular value to
// the end of that day rather than its start, which is what the exclusive upper
// bound of the interval needs; a relative offset already names an instant and
// is not widened.
func parseDateBound(s string, endOfDay bool) (time.Time, error) {
	day := func(t time.Time) time.Time {
		y, m, d := t.Date()
		start := time.Date(y, m, d, 0, 0, 0, 0, t.Location())
		if endOfDay {
			return start.AddDate(0, 0, 1)
		}
		return start
	}
	now := time.Now()
	switch strings.ToLower(strings.TrimSpace(s)) {
	case "today":
		return day(now), nil
	case "yesterday":
		return day(now.AddDate(0, 0, -1)), nil
	}
	if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil {
		return day(t), nil
	}
	if d, ok := parseRelative(s); ok {
		return now.Add(d), nil
	}
	return time.Time{}, fmt.Errorf(
		"cannot parse %q: want YYYY-MM-DD, today, yesterday, or an offset like -7d", s)
}

// parseRelative parses an offset like -7d, 24h or -2w into a duration. The sign
// is optional and always read as "into the past": nobody asks a mail archive
// for messages from next week, and silently returning nothing for a missing
// minus sign would be the confusing reading.
func parseRelative(s string) (time.Duration, bool) {
	s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "-"))
	if len(s) < 2 {
		return 0, false
	}
	unit := s[len(s)-1]
	n, err := strconv.Atoi(s[:len(s)-1])
	if err != nil || n < 0 {
		return 0, false
	}
	switch unit {
	case 'h', 'H':
		return -time.Duration(n) * time.Hour, true
	case 'd', 'D':
		return -time.Duration(n) * 24 * time.Hour, true
	case 'w', 'W':
		return -time.Duration(n) * 7 * 24 * time.Hour, true
	}
	return 0, false
}

// sql renders the range as a WHERE clause and its arguments, or "" when the
// range is unbounded at both ends. The clause has a leading space so it can be
// concatenated onto a FROM clause either way.
// The column holding the timestamp is named by the caller: the two mirrors in
// this tree spell it differently, and a hardcoded name would silently produce
// a query against a column that does not exist in one of them.
func (d DateRange) SQL(column string) (string, []any) {
	clauses, args := d.Clauses(column)
	if len(clauses) == 0 {
		return "", nil
	}
	return " WHERE " + strings.Join(clauses, " AND "), args
}

// Clauses is SQL without the WHERE keyword, for callers that have other
// conditions to combine it with.
func (d DateRange) Clauses(column string) ([]string, []any) {
	var clauses []string
	var args []any
	if !d.Since.IsZero() {
		clauses = append(clauses, column+" >= ?")
		args = append(args, d.Since.Unix())
	}
	if !d.Until.IsZero() {
		// Half-open: the upper bound is the first instant *not* included, so a
		// message stamped exactly at midnight belongs to the following day and
		// is not counted twice by two adjacent ranges.
		clauses = append(clauses, column+" < ?")
		args = append(args, d.Until.Unix())
	}
	return clauses, args
}

// describe renders the range for a heading, echoing the resolved bounds rather
// than what was asked for, so that a reader can see what "today" or "-7d" was
// taken to mean.
func (d DateRange) Describe() string {
	const layout = "2006-01-02 15:04"
	switch {
	case d.Since.IsZero() && d.Until.IsZero():
		return ""
	case d.Until.IsZero():
		return "since " + d.Since.Format(layout)
	case d.Since.IsZero():
		return "before " + d.Until.Format(layout)
	default:
		return d.Since.Format(layout) + " to " + d.Until.Format(layout)
	}
}