-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathinbound_filter.go
More file actions
342 lines (310 loc) · 11.7 KB
/
Copy pathinbound_filter.go
File metadata and controls
342 lines (310 loc) · 11.7 KB
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
// Who is allowed to send us mail.
//
// This instance does not accept mail from strangers. Four things get a message
// through, and a sender needs only one of them:
//
// 1. It is a reply to something we sent — In-Reply-To or References matches a
// Message-ID we generated.
// 2. We have written to that address before, which is recorded on the way out.
// 3. The sender's domain is on the whitelist — see Whitelisted below for what
// is on it and how to add to it.
// 4. The sender's address is verified on an account here. Somebody who proved
// they own a mailbox is not a stranger, whatever their domain.
//
// There was a fifth: mail addressed to support@ and nothing else, which was
// public on purpose so somebody who could not pay had a way to say so. It was
// also the only address a spammer could reach, and a per-sender cap does
// nothing about a thousand senders. Gone, with the address and the page.
//
// Everything else is refused with a 550, so the sender's own server tells them
// rather than the message vanishing.
//
// The list used to say three, and had said three for a while after it became
// four. If you add a rule, say so here — this comment is the only place the
// policy is written down in one piece, and an operator deciding whether to
// whitelist a domain is reading it.
package mail
import (
"strings"
"sync"
"mu/internal/auth"
"mu/internal/data"
"mu/internal/settings"
)
// ── Outbound message ID tracking ────────────────────────────
// We record every Message-ID we generate so we can recognise replies.
var (
sentMu sync.RWMutex
sentMsgIDs = map[string]bool{} // Message-ID → true
sentToAddr = map[string]bool{} // email addresses we've sent to
)
func init() {
data.LoadJSON("mail_sent_ids.json", &sentMsgIDs)
data.LoadJSON("mail_sent_to.json", &sentToAddr)
var loaded map[string]bool
if err := data.LoadJSON("mail_whitelist.json", &loaded); err == nil && loaded != nil {
customWhitelist = loaded
}
}
// RecordOutbound stores a sent message's ID and recipient so future
// replies and mail from that address are allowed through.
func RecordOutbound(messageID, toAddr string) {
sentMu.Lock()
defer sentMu.Unlock()
if messageID != "" {
sentMsgIDs[messageID] = true
// Cap at 10k to prevent unbounded growth.
if len(sentMsgIDs) > 10000 {
i := 0
for k := range sentMsgIDs {
if i > 1000 {
break
}
delete(sentMsgIDs, k)
i++
}
}
}
if toAddr != "" {
sentToAddr[strings.ToLower(toAddr)] = true
}
data.SaveJSON("mail_sent_ids.json", sentMsgIDs)
data.SaveJSON("mail_sent_to.json", sentToAddr)
}
// isReplyToOurMail checks if In-Reply-To or References contain a
// Message-ID we generated.
func isReplyToOurMail(inReplyTo, references string) bool {
sentMu.RLock()
defer sentMu.RUnlock()
for _, id := range extractMessageIDs(inReplyTo + " " + references) {
if sentMsgIDs[id] {
return true
}
}
return false
}
// isSentToAddress returns true if we've previously sent mail to this
// address (auto-whitelisted on outbound).
func isSentToAddress(addr string) bool {
sentMu.RLock()
defer sentMu.RUnlock()
return sentToAddr[strings.ToLower(addr)]
}
// extractMessageIDs pulls <...> bracketed IDs from a header value.
func extractMessageIDs(s string) []string {
var ids []string
for {
start := strings.Index(s, "<")
if start < 0 {
break
}
end := strings.Index(s[start:], ">")
if end < 0 {
break
}
ids = append(ids, s[start:start+end+1])
s = s[start+end+1:]
}
return ids
}
// ── Domain whitelist ────────────────────────────────────────
// Product/company domains whose automated mail is always allowed.
// Consumer addresses (gmail.com, outlook.com, etc.) are NOT here —
// those only get through if the user sent to them first.
var domainWhitelist = map[string]bool{
// Google
"google.com": true, "youtube.com": true, "googleapis.com": true,
// Microsoft
"microsoft.com": true, "outlook.com": false, "hotmail.com": false,
"live.com": false, "microsoftonline.com": true, "azure.com": true,
// Apple
"apple.com": true, "icloud.com": false,
// GitHub
"github.com": true,
// Amazon
"amazon.com": true, "amazon.co.uk": true, "amazonaws.com": true,
// Stripe
"stripe.com": true,
// Social
"twitter.com": true, "x.com": true, "linkedin.com": true,
"facebook.com": true, "instagram.com": true,
// Dev tools
"gitlab.com": true, "bitbucket.org": true, "atlassian.com": true,
"notion.so": true, "slack.com": true, "zoom.us": true,
"figma.com": true, "vercel.com": true, "netlify.com": true,
"cloudflare.com": true, "digitalocean.com": true,
"fly.io": true, "render.com": true, "railway.app": true,
"heroku.com": true, "supabase.com": true, "firebase.google.com": true,
// Payments / finance
"paypal.com": true, "wise.com": true, "revolut.com": true,
"monzo.com": true, "coinbase.com": true, "binance.com": true,
// Shipping / commerce
"royalmail.com": true, "dpd.co.uk": true, "ups.com": true,
"fedex.com": true, "dhl.com": true, "ebay.com": true,
"etsy.com": true, "shopify.com": true,
// Comms
"sendgrid.net": true, "mailchimp.com": true, "mailgun.com": true,
"postmarkapp.com": true, "twilio.com": true,
// UK services
"gov.uk": true, "nhs.uk": true, "hmrc.gov.uk": true,
// Security
"letsencrypt.org": true, "cloudflare.net": true,
// Email infrastructure (DMARC reports, etc.)
"dmarc.yahoo.com": true,
// Mu
"micro.mu": true, "reminder.dev": true,
}
// Custom whitelist additions (persisted, managed by admin).
var (
customWhitelistMu sync.RWMutex
customWhitelist = map[string]bool{}
)
// Whitelisted is the set of domains an operator has added, as a comma or space
// separated list: MAIL_WHITELIST="acme.com, partner.co.uk".
//
// It exists because the whitelist was unreachable. There is a
// mail_whitelist.json read at startup and nothing in the product ever wrote
// it — no page, no setting, no command — so "add a domain" meant knowing that
// file existed, finding it under ~/.mu/data, hand-editing JSON and restarting.
// That is not a policy an operator can hold. A setting is live-reloadable and
// appears at /admin/config beside everything else.
//
// The file still works: both are consulted, so an instance that already had one
// keeps it.
func Whitelisted() []string {
raw := settings.Get("MAIL_WHITELIST")
if strings.TrimSpace(raw) == "" {
return nil
}
var out []string
for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ' ' || r == '\n' || r == '\t'
}) {
if d := strings.ToLower(strings.TrimSpace(part)); d != "" {
out = append(out, strings.TrimPrefix(d, "@"))
}
}
return out
}
// isWhitelistedDomain checks both built-in and custom whitelists.
// Returns false for consumer email domains (gmail, outlook, etc.)
// even if they're in the map — those are explicitly set to false.
func isWhitelistedDomain(domain string) bool {
domain = strings.ToLower(domain)
// The operator's own list, from the setting.
for _, d := range Whitelisted() {
if d == domain {
return true
}
}
// Check custom whitelist first (admin-added).
customWhitelistMu.RLock()
if customWhitelist[domain] {
customWhitelistMu.RUnlock()
return true
}
customWhitelistMu.RUnlock()
// Check built-in list. Entries set to false (gmail, outlook, etc.)
// are explicitly NOT whitelisted.
if allowed, exists := domainWhitelist[domain]; exists {
return allowed
}
// Check parent domain for subdomains (e.g. mail.google.com → google.com).
parts := strings.SplitN(domain, ".", 2)
if len(parts) == 2 {
if allowed, exists := domainWhitelist[parts[1]]; exists {
return allowed
}
}
return false
}
// CheckInboundAllowed decides whether an inbound email should be
// accepted. Returns ("", true) if allowed, or (reason, false) if
// it should be rejected.
func CheckInboundAllowed(fromAddr string, to []string, inReplyTo, references string) (string, bool) {
// There is no support bypass any more.
//
// support@ was the one address the whitelist did not apply to, because the
// point of it was to hear from people this instance had never heard of.
// That is also what made it the one address a spammer could reach, and a
// per-sender cap did nothing about a thousand senders. The whitelist is the
// whole rule now: mail here is from somebody the account knows, or it is a
// reply to something we sent.
// 1. Is it a reply to something we sent?
if inReplyTo != "" || references != "" {
if isReplyToOurMail(inReplyTo, references) {
return "", true
}
}
// 2. Is the sender an address we've previously emailed?
if isSentToAddress(fromAddr) {
return "", true
}
// 3. Is the sender's domain whitelisted?
parts := strings.Split(strings.ToLower(fromAddr), "@")
if len(parts) == 2 && isWhitelistedDomain(parts[1]) {
return "", true
}
// 4. Is the sender a verified email on an account here?
//
// Somebody who clicked a link in a mailbox to prove they own it is not a
// stranger, and their own address should not need an operator to whitelist
// its domain by hand. Without this, writing to your own agent from a
// personal address only worked if that domain happened to be on a list
// written for company mail.
if VerifiedAccountAddress(fromAddr) {
return "", true
}
return "sender not in whitelist and message is not a reply", false
}
// isOwnVerifiedAddress reports whether an inbound sender is the recipient's
// own verified email — you, writing to your own address or to one of your
// agents' aliases.
//
// This is the one relationship the instance can be certain about: the account
// holder proved they control that mailbox, by clicking a link in it or by
// sending back a code that arrived there. Mail from it to themselves is never
// spam and never needs whitelisting, and treating it like any other stranger is
// what made "email your agent" — the first thing anyone tries — fail silently
// into a folder.
//
// Any address the account has proved, not only the one it signs in with. That
// used to be the same thing, and the gap it left was the ordinary case: you
// sign up from a personal address and then write to your agent from work.
func isOwnVerifiedAddress(acc *auth.Account, fromAddr string) bool {
return acc.Owns(fromAddr)
}
// SenderIsAccountOwner reports whether an address is the verified email of one
// particular account — the same question as isOwnVerifiedAddress, asked by
// account id rather than by record.
//
// Distinct from VerifiedAccountAddress below, which asks whether an address
// belongs to *anybody* here. That is the right question for letting mail in
// and the wrong one for letting a sender drive an agent: every account holder
// on the instance would qualify.
func SenderIsAccountOwner(ownerID, fromAddr string) bool {
if ownerID == "" {
return false
}
acc, err := auth.GetAccount(ownerID)
if err != nil {
return false
}
return isOwnVerifiedAddress(acc, fromAddr)
}
// AccountForVerifiedEmail finds the account that proved it owns an address.
//
// The shared agent mailbox has no owner in its own name — agent@<domain>
// belongs to the instance — so whose mail it is has to come from who sent it.
// A verified email is the only claim strong enough to answer that: the person
// proved they can read the mailbox. Nil when nobody has.
func AccountForVerifiedEmail(fromAddr string) *auth.Account {
return auth.AccountForAddress(fromAddr)
}
// VerifiedAccountAddress reports whether an address is the verified email of
// any account on this instance. Used by the inbound whitelist: somebody who
// proved they own a mailbox is not a stranger, so their mail should reach this
// instance without an operator adding their domain by hand.
func VerifiedAccountAddress(fromAddr string) bool {
return auth.AccountForAddress(fromAddr) != nil
}