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

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

// mailtoURI holds a parsed mailto: URI.
type mailtoURI struct {
	Address string // plain email address, e.g. "unsub@example.com"
	Subject string // value of ?subject= query param, "" if absent
}

// parseMailto parses a single mailto: URI string (without the "mailto:" prefix)
// into a mailtoURI. Query parameters other than "subject" are ignored.
func parseMailto(s string) mailtoURI {
	addr := s
	var subject string
	if before, after, ok := strings.Cut(s, "?"); ok {
		addr = before
		query := after
		for kv := range strings.SplitSeq(query, "&") {
			if k, v, ok := strings.Cut(kv, "="); ok && strings.EqualFold(k, "subject") {
				subject = v
			}
		}
	}
	return mailtoURI{Address: contactAddress(addr), Subject: subject}
}

// parseListUnsubscribeMailtoFull extracts the first mailto: URI from a
// List-Unsubscribe header value and returns a parsed mailtoURI.
// Returns zero value if no mailto: is found.
func parseListUnsubscribeMailtoFull(header string) mailtoURI {
	for part := range strings.SplitSeq(header, ",") {
		part = strings.TrimSpace(part)
		part = strings.Trim(part, "<>")
		if strings.HasPrefix(strings.ToLower(part), "mailto:") {
			return parseMailto(part[len("mailto:"):])
		}
	}
	return mailtoURI{}
}

// unsubInfo holds the parsed unsubscribe options from a List-Unsubscribe header.
type unsubInfo struct {
	Mailto string // bare email address from mailto: URI, "" if none
	URL    string // first https:// URL, "" if none
}

// parseListUnsubscribeInfo parses a full List-Unsubscribe header value into
// an unsubInfo, extracting both the mailto: address and the first https:// URL.
func parseListUnsubscribeInfo(header string) unsubInfo {
	var info unsubInfo
	for part := range strings.SplitSeq(header, ",") {
		part = strings.TrimSpace(part)
		part = strings.Trim(part, "<>")
		lower := strings.ToLower(part)
		if info.Mailto == "" && strings.HasPrefix(lower, "mailto:") {
			info.Mailto = parseMailto(part[len("mailto:"):]).Address
		}
		if info.URL == "" && strings.HasPrefix(lower, "https://") {
			info.URL = part
		}
	}
	return info
}

// loadUnsubscribeInfo queries message_headers for the list-unsubscribe header
// for each message ID in ids and returns a map of id → unsubInfo.
func loadUnsubscribeInfo(db *sql.DB, ids []int64) (map[int64]unsubInfo, error) {
	if len(ids) == 0 {
		return nil, nil
	}
	placeholders := make([]string, len(ids))
	args := make([]any, len(ids))
	for i, id := range ids {
		placeholders[i] = "?"
		args[i] = id
	}
	rows, err := db.Query(
		`SELECT message_id, value FROM message_headers
		 WHERE name = 'list-unsubscribe'
		   AND message_id IN (`+strings.Join(placeholders, ",")+`)`,
		args...,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	result := make(map[int64]unsubInfo)
	for rows.Next() {
		var id int64
		var value string
		if err := rows.Scan(&id, &value); err != nil {
			return nil, err
		}
		info := parseListUnsubscribeInfo(value)
		if info.Mailto != "" || info.URL != "" {
			result[id] = info
		}
	}
	return result, rows.Err()
}

// loadUnsubscribeRequests returns a set of message IDs that have an
// x-mailweb-unsubscribe header (i.e. are unsubscribe mails we sent).
func loadUnsubscribeRequests(db *sql.DB, ids []int64) (map[int64]bool, error) {
	if len(ids) == 0 {
		return nil, nil
	}
	placeholders := make([]string, len(ids))
	args := make([]any, len(ids))
	for i, id := range ids {
		placeholders[i] = "?"
		args[i] = id
	}
	rows, err := db.Query(
		`SELECT message_id FROM message_headers
		 WHERE name = 'x-mailweb-unsubscribe'
		   AND message_id IN (`+strings.Join(placeholders, ",")+`)`,
		args...,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	result := make(map[int64]bool)
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		result[id] = true
	}
	return result, rows.Err()
}

// latestListUnsubscribe returns the List-Unsubscribe header of the most recent
// message whose sender is exactly address, preferring one that offers a mailto:.
//
// The candidates are narrowed in SQL with a substring match, which is only a
// prefilter: every row is then checked with parseFromAddr, so a sender that
// merely contains the address is discarded rather than answered for. That
// second step is the point — the header decides who receives mail sent under
// the account owner's name, and a substring can name a sender the request never
// asked about.
//
// ok is false when no message from exactly this address carries the header,
// which is different from a database error and is answered differently.
func latestListUnsubscribe(db *sql.DB, address string) (value string, ok bool, err error) {
	rows, err := db.Query(
		`SELECT h.value, m.from_addr FROM message_headers h
		 JOIN messages m ON m.id = h.message_id
		 WHERE h.name = 'list-unsubscribe'
		   AND LOWER(m.from_addr) LIKE ?
		 ORDER BY
		   CASE WHEN LOWER(h.value) LIKE '%mailto:%' THEN 0 ELSE 1 END,
		   m.date DESC`,
		"%"+address+"%",
	)
	if err != nil {
		return "", false, err
	}
	defer rows.Close()
	for rows.Next() {
		var val, fromAddr string
		if err := rows.Scan(&val, &fromAddr); err != nil {
			return "", false, err
		}
		if _, from := parseFromAddr(fromAddr); from == address {
			return val, true, nil
		}
	}
	return "", false, rows.Err()
}

// handleUnsubscribeContact handles POST /unsubscribe/contact/{addr}.
// Finds the most recent list-unsubscribe mailto for the contact and sends to it.
func (s *server) handleUnsubscribeContact(w http.ResponseWriter, r *http.Request) {
	address := contactAddress(r.PathValue("addr"))

	// The header is taken from a message this contact actually sent, matched on
	// the parsed sender rather than on a substring of from_addr.
	//
	// A LIKE here chose which third party to mail: it matches any sender the
	// path is a substring of, takes the first header found and sends to
	// whatever address that header names. Nothing in the request has to
	// resemble the recipient. In this mirror, /unsubscribe/contact/hetzner.com
	// selects invoices that arrive via a mailing list and would send the
	// unsubscribe request to the list, which is not what the URL asked for and
	// not something the reply would reveal — unlike a spam report, which at
	// least attaches the messages it is about.
	//
	// Selecting candidates fuzzily is fine, and the contact view still does it.
	// Choosing the recipient of mail sent under the account owner's name is not.
	unsubValue, ok, err := latestListUnsubscribe(s.db.Read, address)
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}
	if !ok {
		http.Error(w, "no list-unsubscribe header found for contact: no message "+
			"from this exact address carries one", http.StatusNotFound)
		return
	}
	info := parseListUnsubscribeInfo(unsubValue)

	ref := r.Header.Get("Referer")
	if ref == "" {
		ref = "/contacts"
	}

	if info.Mailto != "" {
		if s.smtp.host == "" {
			http.Error(w, "SMTP not configured", http.StatusServiceUnavailable)
			return
		}
		to := parseListUnsubscribeMailtoFull(unsubValue)
		subject := to.Subject
		if subject == "" {
			subject = "unsubscribe"
		}
		log.Printf("unsubscribe contact: sending to %s for contact %s", to.Address, address)
		raw, err := sendMail(s.smtp, s.fromAddr, to.Address, subject, "", "text/plain", address)
		if err != nil {
			log.Printf("handleUnsubscribeContact: sendMail error: %v", err)
			http.Error(w, fmt.Sprintf("send error: %v", err), http.StatusInternalServerError)
			return
		}
		if err := appendToSent(s.imapCreds, raw); err != nil {
			log.Printf("handleUnsubscribeContact: appendToSent: %v", err)
		}
	} else if info.URL != "" {
		// URL-only: record intent in the mailweb folder.
		body := "Unsubscribe request recorded for " + address + ".\nURL: " + info.URL
		subject := "[mailweb] Unsubscribe request for " + address
		raw, err := buildActionMessage(s.fromAddr, address, subject, body,
			map[string]string{"X-Mailweb-Unsubscribe-URL": info.URL})
		if err != nil {
			log.Printf("handleUnsubscribeContact: buildActionMessage: %v", err)
		} else if err := appendToMailweb(s.imapCreds, s.mailwebFolder, raw); err != nil {
			log.Printf("handleUnsubscribeContact: appendToMailweb: %v", err)
		}
	} else {
		http.Error(w, "no unsubscribe address or URL found", http.StatusNotFound)
		return
	}

	http.Redirect(w, r, ref, http.StatusSeeOther)
}

// handleUnsubscribe handles POST /unsubscribe/{id}.
// Dispatches on mailto vs URL in the List-Unsubscribe header.
func (s *server) handleUnsubscribe(w http.ResponseWriter, r *http.Request) {
	var msgID int64
	if _, err := fmt.Sscan(r.PathValue("id"), &msgID); err != nil {
		http.Error(w, "invalid message id", http.StatusBadRequest)
		return
	}

	var headerValue, fromAddr string
	err := s.db.Read.QueryRow(
		`SELECT h.value, m.from_addr
		 FROM message_headers h JOIN messages m ON m.id = h.message_id
		 WHERE h.message_id = ? AND h.name = 'list-unsubscribe' LIMIT 1`,
		msgID,
	).Scan(&headerValue, &fromAddr)
	if err != nil {
		http.Error(w, "no list-unsubscribe header for this message", http.StatusNotFound)
		return
	}
	info := parseListUnsubscribeInfo(headerValue)
	_, fromPlain := parseFromAddr(fromAddr)

	ref := r.Header.Get("Referer")
	if ref == "" {
		ref = "/"
	}

	if info.Mailto != "" {
		if s.smtp.host == "" {
			http.Error(w, "SMTP not configured", http.StatusServiceUnavailable)
			return
		}
		to := parseListUnsubscribeMailtoFull(headerValue)
		subject := to.Subject
		if subject == "" {
			subject = "unsubscribe"
		}
		log.Printf("unsubscribe: sending to %s for message %d (contact %s)", to.Address, msgID, fromPlain)
		raw, err := sendMail(s.smtp, s.fromAddr, to.Address, subject, "", "text/plain", fromPlain)
		if err != nil {
			log.Printf("handleUnsubscribe: sendMail error: %v", err)
			http.Error(w, fmt.Sprintf("send error: %v", err), http.StatusInternalServerError)
			return
		}
		if err := appendToSent(s.imapCreds, raw); err != nil {
			log.Printf("handleUnsubscribe: appendToSent: %v", err)
		}
	} else if info.URL != "" {
		// URL-only: record intent in the mailweb folder.
		body := "Unsubscribe request recorded for " + fromPlain + ".\nURL: " + info.URL
		subject := "[mailweb] Unsubscribe request for " + fromPlain
		raw, err := buildActionMessage(s.fromAddr, fromPlain, subject, body,
			map[string]string{"X-Mailweb-Unsubscribe-URL": info.URL})
		if err != nil {
			log.Printf("handleUnsubscribe: buildActionMessage: %v", err)
		} else if err := appendToMailweb(s.imapCreds, s.mailwebFolder, raw); err != nil {
			log.Printf("handleUnsubscribe: appendToMailweb: %v", err)
		}
	} else {
		http.Error(w, "no unsubscribe address or URL found", http.StatusNotFound)
		return
	}

	http.Redirect(w, r, ref, http.StatusSeeOther)
}