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

// Petnames.
//
// A contact's name, everywhere mailweb printed one, came out of the From:
// header — which is written by whoever sent the mail and checked against
// nothing. That was already the weakest claim on the page, and it was rendered
// in the same voice as everything else. This archive contains
//
//	Deutsche-Baпk AG <andf@informerbooks.com>
//	DKB AG <adriana.alvarado@ine.mx>
//
// the first of which spells "Bank" with a Cyrillic п. mailweb printed both as
// the contact's name, in bold, at the top of a page whose every link it also
// minted. The problem is not that the strings are hostile; it is that nothing
// on the page distinguished them from a name the reader had chosen.
//
// A petname is a name the account owner assigns to an address. It is the only
// name on the page that anybody here vouches for, because it is the only one
// that was not transmitted. The pairing is what makes it work:
//
//	~klara <klara@example.org>              a name you chose
//	"Deutsche-Baпk AG" <andf@example.com>   a name the sender chose
//
// Both are shown — an unnamed contact still renders its claimed name, since
// twenty-eight thousand addresses will never all be named and a listing of bare
// addresses cannot be skimmed. What changes is that the claimed name is now
// marked as claimed, so the reader can tell which of the two they are looking
// at without having to remember which kind of page they are on.
//
// This is Zooko's triangle as it actually turns up in mail: the address is the
// globally unique, non-memorable key; the display name is the memorable,
// non-unique, unauthenticated one; the petname is memorable, and unique within
// this database, precisely because it never travels. mailweb cannot make an
// address authentic — see "Senders are not authenticated" — so it stops
// implying that it has.

import (
	"database/sql"
	"fmt"
	"sort"
	"strings"
	"time"
	"unicode"

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

// ============================================================================
// Storage
// ============================================================================

// setPetname assigns a petname to an address, replacing any existing one.
//
// The address is canonicalised through contactAddress for the same reason
// contact_flags is: a petname stored under any other spelling of the string
// matches no contact that will ever be listed, and nothing would report the
// mismatch.
func setPetname(db *sql.DB, address, petname string) error {
	address = contactAddress(address)
	petname = strings.TrimSpace(petname)
	if petname == "" {
		return clearPetname(db, address)
	}
	_, err := db.Exec(
		`INSERT INTO contact_petnames(address, petname, set_at) VALUES (?, ?, ?)
		 ON CONFLICT(address) DO UPDATE SET petname = excluded.petname,
		                                    set_at  = excluded.set_at`,
		address, petname, time.Now().Unix(),
	)
	return err
}

// clearPetname removes the petname for an address, which returns the contact to
// showing whatever name its mail claims.
func clearPetname(db *sql.DB, address string) error {
	_, err := db.Exec(`DELETE FROM contact_petnames WHERE address = ?`,
		contactAddress(address))
	return err
}

// loadPetnames returns every petname, keyed by canonical address.
//
// All of them, rather than the ones a page happens to need: a petname is
// assigned by hand, so the table holds tens or hundreds of rows against tens of
// thousands of messages, and one query that a listing can consult per row beats
// a lookup per row.
func loadPetnames(db *sql.DB) (map[string]string, error) {
	rows, err := db.Query(`SELECT address, petname FROM contact_petnames`)
	if err != nil {
		return nil, fmt.Errorf("load petnames: %w", err)
	}
	defer rows.Close()
	out := make(map[string]string)
	for rows.Next() {
		var address, petname string
		if err := rows.Scan(&address, &petname); err != nil {
			return nil, fmt.Errorf("scan petname: %w", err)
		}
		out[address] = petname
	}
	return out, rows.Err()
}

// There is deliberately no lookup from a petname to an address, and no
// single-address variant of loadPetnames either.
//
// The first is the rule: a petname may name any number of addresses — one
// correspondent with a work address and a personal one is properly given the
// same name twice — so resolving a name to an address means guessing which
// correspondent was meant. Guessing wrong sends mail written for one person to
// another, under the account owner's name, with nothing in the result saying
// so. contact_petnames therefore carries no unique index on petname: making it
// a key is exactly the property that must not exist. See "Petnames are not
// addresses" in mailweb(7).
//
// The second is a smaller thing that leads to the first. A `petnameOf(address)`
// existed here, unused, and the only code that would ever have reached for it
// is a compose feature wanting to turn something the reader typed into a
// recipient. A lookup that exists gets called; leaving it here would have been
// leaving the shape of the mistake lying around for the next person to pick up.
// Renderers load the whole table once per page — it holds tens of rows against
// tens of thousands of messages — and index it by address.

// sharersOf returns the other addresses carrying the same petname as address,
// sorted. Empty when the name is unique or the address has none.
//
// Sorted because the source is a map, and map iteration order is randomised per
// range in Go: unsorted, the notice would list the same addresses in a
// different order on every reload, which reads as something having changed.
//
// This is what makes a duplicate visible. Assigning a name already in use is
// allowed and is sometimes exactly right, so the settings page states how many
// addresses now share it and which — a duplicate made on purpose reads as
// confirmation, one made by accident as a surprise, and without the notice the
// two are indistinguishable.
//
// It is not the petname→address lookup the block above refuses, though it is
// close enough to be worth saying why. That lookup takes a name somebody typed
// and produces an address to act on; this takes an address that is already in
// hand, reads its name out of storage, and produces a list to display. Nothing
// here chooses a correspondent: the caller knows which address it is rendering
// before it asks, and the answer is never used as a recipient, a key, or an
// argument to anything that writes. A name matching several addresses is the
// case this reports rather than the case it has to resolve — which is the whole
// difference.
func sharersOf(petnames map[string]string, address string) []string {
	address = contactAddress(address)
	name := petnames[address]
	if name == "" {
		return nil
	}
	var out []string
	for other, otherName := range petnames {
		if other != address && otherName == name {
			out = append(out, other)
		}
	}
	sort.Strings(out)
	return out
}

// ============================================================================
// Display
// ============================================================================
//
// The rendered form of a name lives in mailtext, beside the region markers,
// because it is the same trust boundary at the scale of one field and must mean
// the same thing in every frontend that shares the package. What stays here is
// where a petname is stored and how one is resolved, which is mailweb's own
// business: one account per process, addresses keyed the way contact_flags
// keys them.

// resolveDisplay builds a Name from a stored "Name <addr>" string.
func resolveDisplay(petnames map[string]string, fromAddr string) mailtext.Name {
	claimed, address := parseFromAddr(fromAddr)
	return resolveAddress(petnames, address, claimed)
}

// resolveAddress builds a Name from an already-split address and display name,
// which is what the JSON address columns hold.
func resolveAddress(petnames map[string]string, address, claimed string) mailtext.Name {
	address = contactAddress(address)
	claimed = strings.TrimSpace(claimed)
	// A display name that merely repeats the address is not a name; showing it
	// twice on one line says nothing and costs a column in every listing.
	if strings.EqualFold(claimed, address) {
		claimed = ""
	}
	return mailtext.Name{
		Address: address,
		Petname: petnames[address],
		Claimed: claimed,
		Mixed:   mixesScripts(claimed),
	}
}

// resolveDisplays builds Names for a JSON address list, as stored in to_addrs,
// cc_addrs and bcc_addrs.
func resolveDisplays(petnames map[string]string, addrs []addr) []mailtext.Name {
	out := make([]mailtext.Name, 0, len(addrs))
	for _, a := range addrs {
		out = append(out, resolveAddress(petnames, a.Address, a.Name))
	}
	return out
}

// ============================================================================
// Homograph detection
// ============================================================================

// mixesScripts reports whether any single word of a name draws on more than one
// of the Latin, Cyrillic and Greek alphabets.
//
// That is how a homograph is built: "Deutsche-Baпk AG" is a Cyrillic п in an
// otherwise Latin word, indistinguishable at a glance and a different string to
// every comparison. Flagging it costs a scan of a name that is being rendered
// anyway.
//
// The test is per word rather than per name, which is what keeps it honest. A
// contact who signs "Ivan Иванов" writes two words, each in one alphabet, and
// is not flagged; a name is only suspicious when the alphabets meet inside a
// single run of letters, where no typography separates them. Applied to the
// whole name, every transliterated signature in the archive would be marked and
// the mark would stop meaning anything.
//
// This detects a technique, not an intent. It is rendered as an observation
// about the name — that it mixes alphabets — rather than as a verdict, because
// mailweb cannot tell a phish from a company with an unusual wordmark.
func mixesScripts(name string) bool {
	for _, word := range strings.FieldsFunc(name, func(r rune) bool {
		return unicode.IsSpace(r)
	}) {
		var latin, cyrillic, greek bool
		for _, r := range word {
			switch {
			case unicode.Is(unicode.Latin, r):
				latin = true
			case unicode.Is(unicode.Cyrillic, r):
				cyrillic = true
			case unicode.Is(unicode.Greek, r):
				greek = true
			}
		}
		n := 0
		for _, seen := range []bool{latin, cyrillic, greek} {
			if seen {
				n++
			}
		}
		if n > 1 {
			return true
		}
	}
	return false
}