-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinbound.go
More file actions
624 lines (547 loc) · 20 KB
/
Copy pathinbound.go
File metadata and controls
624 lines (547 loc) · 20 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
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
package smtp
import (
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net"
"strings"
"sync"
"time"
"gomail/auth"
"gomail/config"
"gomail/dns"
"gomail/parser"
"gomail/store"
)
// InboundServer listens for incoming SMTP connections.
type InboundServer struct {
cfg *config.Config
db *store.DB
tlsConfig *tls.Config
rateLimiter *RateLimiter
listener net.Listener
wg sync.WaitGroup
quit chan struct{}
connSemaphore chan struct{} // Limits concurrent connections
}
// NewInboundServer creates a new inbound SMTP server.
func NewInboundServer(cfg *config.Config, db *store.DB, tlsCfg *tls.Config) *InboundServer {
return &InboundServer{
cfg: cfg,
db: db,
tlsConfig: tlsCfg,
rateLimiter: NewRateLimiter(
cfg.SMTP.RateLimit.ConnectionsPerMinute,
cfg.SMTP.RateLimit.MessagesPerMinute,
),
quit: make(chan struct{}),
connSemaphore: make(chan struct{}, cfg.SMTP.MaxConnections),
}
}
// Start begins listening for SMTP connections.
func (s *InboundServer) Start() error {
var err error
s.listener, err = net.Listen("tcp", s.cfg.SMTP.ListenAddr)
if err != nil {
return fmt.Errorf("SMTP listen on %s: %w", s.cfg.SMTP.ListenAddr, err)
}
log.Printf("[smtp] inbound listening on %s", s.cfg.SMTP.ListenAddr)
s.wg.Add(1)
go s.acceptLoop()
return nil
}
// Stop gracefully shuts down the SMTP server.
func (s *InboundServer) Stop() {
close(s.quit)
if s.listener != nil {
s.listener.Close()
}
s.wg.Wait()
log.Println("[smtp] inbound server stopped")
}
func (s *InboundServer) acceptLoop() {
defer s.wg.Done()
for {
conn, err := s.listener.Accept()
if err != nil {
select {
case <-s.quit:
return
default:
log.Printf("[smtp] accept error: %v", err)
continue
}
}
// Rate limit by IP
ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
if !s.rateLimiter.AllowConnection(ip) {
log.Printf("[smtp] rate limit: rejecting connection from %s", ip)
conn.Close()
continue
}
// Try to acquire connection slot
select {
case s.connSemaphore <- struct{}{}:
// Slot acquired, handle the connection
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer func() { <-s.connSemaphore }() // Release slot when done
s.handleConnection(conn)
}()
default:
// No slots available, reject connection
log.Printf("[smtp] max connections reached: rejecting connection from %s", ip)
conn.Close()
}
}
}
func (s *InboundServer) handleConnection(conn net.Conn) {
// Get current list of accepted domains from DB
domains, err := s.db.ListAllDomainNames()
if err != nil {
log.Printf("[smtp] error loading domains: %v", err)
conn.Close()
return
}
// Verify reverse DNS (PTR) for the connecting IP
ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
ptrHostname, ptrValid, err := dns.VerifyPTR(ip)
if err != nil {
log.Printf("[smtp] PTR lookup failed for %s: %v", ip, err)
} else {
if ptrValid {
log.Printf("[smtp] PTR verified for %s: %s", ip, ptrHostname)
} else {
log.Printf("[smtp] PTR hostname %s does not resolve back to %s (forward confirmation failed)", ptrHostname, ip)
}
}
accountExists := func(email string) bool {
acct, err := s.db.GetAccountByEmail(email)
return err == nil && acct != nil && acct.IsActive
}
session := NewSession(
conn,
s.cfg.Server.Hostname,
domains,
accountExists,
s.tlsConfig,
s.cfg.SMTP.MaxMessageSize,
s.cfg.SMTP.MaxRecipients,
time.Duration(s.cfg.SMTP.ReadTimeout)*time.Second,
time.Duration(s.cfg.SMTP.WriteTimeout)*time.Second,
ptrHostname,
ptrValid,
s.db,
)
// Override the DATA handler to intercept the message
s.runSession(session)
}
// runSession is like Session.Handle() but with our message processing integrated.
func (s *InboundServer) runSession(sess *Session) {
defer sess.conn.Close()
sess.send(220, fmt.Sprintf("%s ESMTP GoMail ready", sess.hostname))
sess.state = StateReady
for sess.state != StateQuit {
sess.conn.SetReadDeadline(time.Now().Add(sess.readTimeout))
line, err := sess.reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
parts := strings.SplitN(line, " ", 2)
cmd := strings.ToUpper(parts[0])
if cmd == "DATA" {
if sess.state != StateRcpt || len(sess.rcptTo) == 0 {
sess.send(503, "Bad sequence of commands")
continue
}
// Rate limit messages
if !s.rateLimiter.AllowMessage(sess.clientIP) {
sess.send(451, "Too many messages, try again later")
continue
}
sess.handleDATA()
// Process the received message
if sess.data.Len() > 0 {
if err := s.processMessage(sess); err != nil {
log.Printf("[smtp] message processing error from %s: %v", sess.clientAddr, err)
sess.send(451, "Message processing failed, try again later")
} else {
sess.send(250, "OK message accepted for delivery")
}
sess.reset()
}
} else if cmd == "BDAT" {
// Handle BDAT (RFC 3030 CHUNKING)
var arg string
if len(parts) > 1 {
arg = parts[1]
}
// Rate limit messages on first chunk (StateRcpt)
if sess.state == StateRcpt && !s.rateLimiter.AllowMessage(sess.clientIP) {
sess.send(451, "Too many messages, try again later")
continue
}
sess.handleBDAT(arg)
// If LAST chunk was received (state transitions back to StateReady with data), process message
if sess.state == StateReady && sess.data.Len() > 0 {
if err := s.processMessage(sess); err != nil {
log.Printf("[smtp] message processing error from %s: %v", sess.clientAddr, err)
sess.send(451, "Message processing failed, try again later")
} else {
sess.send(250, "OK message accepted for delivery")
}
sess.reset()
}
} else {
sess.handleCommand(line)
}
}
}
// processMessage runs authentication checks, parses, and stores an inbound message.
func (s *InboundServer) processMessage(sess *Session) error {
rawMessage := sess.GetData()
mailFrom := sess.GetMailFrom()
rcptTo := sess.GetRcptTo()
clientIP := sess.GetClientIP()
log.Printf("[smtp] processing message from=%s to=%v ip=%s size=%d",
mailFrom, rcptTo, clientIP, len(rawMessage))
// Log PTR verification result
if sess.ptrValid {
log.Printf("[smtp] PTR verified: %s", sess.ptrHostname)
} else if sess.ptrHostname != "" {
log.Printf("[smtp] PTR unverified: %s (no forward confirmation)", sess.ptrHostname)
} else {
log.Printf("[smtp] PTR: no reverse DNS records found")
}
// --- Add Received header ---
receivedHeader := fmt.Sprintf("Received: from %s\r\n\tby %s\r\n\twith SMTP%s\r\n\tid %s\r\n\tfor <%s>;\r\n\t%s\r\n",
clientIP,
s.cfg.Server.Hostname,
func() string {
if sess.tls {
return "S"
}
return ""
}(),
fmt.Sprintf("%d.%s@%s", time.Now().UnixNano(), clientIP, s.cfg.Server.Hostname),
strings.Join(rcptTo, ">, <"),
time.Now().Format(time.RFC1123Z),
)
rawMessage = append([]byte(receivedHeader), rawMessage...)
// --- Authentication checks ---
authBuilder := auth.NewAuthResultsBuilder(s.cfg.Server.Hostname)
// SPF
spfResult, spfDetail := auth.CheckSPF(clientIP, mailFrom)
authBuilder.AddSPF(spfResult, spfDetail, mailFrom)
log.Printf("[smtp] SPF: %s (%s)", spfResult, spfDetail)
// DKIM
dkimResult, dkimDetail := auth.VerifyDKIM(rawMessage)
dkimDomain := auth.GetDKIMDomain(rawMessage)
authBuilder.AddDKIM(dkimResult, dkimDetail, "", "")
log.Printf("[smtp] DKIM: %s (%s)", dkimResult, dkimDetail)
// DMARC
parsed, err := parser.Parse(rawMessage)
if err != nil {
return fmt.Errorf("parsing message: %w", err)
}
log.Printf("[smtp] Parsed message: subject=%q, textLen=%d, htmlLen=%d, attachments=%d",
parsed.Subject, len(parsed.TextBody), len(parsed.HTMLBody), len(parsed.Attachments))
fromDomain := extractDomain(parsed.From)
spfDomain := extractDomain(mailFrom)
dmarcResult := auth.CheckDMARC(fromDomain, spfResult, spfDomain, dkimResult, dkimDomain)
authBuilder.AddDMARC(dmarcResult.Result, dmarcResult.Details, fromDomain)
log.Printf("[smtp] DMARC: %s (%s)", dmarcResult.Result, dmarcResult.Details)
// Record DMARC feedback for aggregate reporting (RFC 7489)
// Determine disposition based on policy and auth results
disposition := "none"
dkimPass := dkimResult == "pass"
spfPass := spfResult == "pass"
if !dkimPass && !spfPass {
if dmarcResult.Policy == "reject" {
disposition = "reject"
} else if dmarcResult.Policy == "quarantine" {
disposition = "quarantine"
}
}
if err := s.db.SaveDMARCFeedback(fromDomain, clientIP, spfDomain, dkimResult, string(spfResult), disposition); err != nil {
log.Printf("[smtp] warning: failed to save DMARC feedback: %v", err)
}
authResults := authBuilder.Build()
// Prepend Authentication-Results header to raw message so clients can see it
authResultsHeader := authResults + "\r\n"
rawMessage = append([]byte(authResultsHeader), rawMessage...)
// --- Validate ARC Chain (if present) ---
arcValidation := auth.ValidateARCChain(rawMessage)
switch arcValidation.Status {
case "pass":
log.Printf("[smtp] ARC ✓ PASS: valid chain through instance=%d",
arcValidation.HighestValid)
case "fail":
log.Printf("[smtp] ARC ✗ FAIL: %s", arcValidation.Details)
case "permerror":
log.Printf("[smtp] ARC ⚠ PERMERROR: permanent error - %s", arcValidation.Details)
case "temperror":
log.Printf("[smtp] ARC ⏱ TEMPERROR: temporary error - %s", arcValidation.Details)
case "none":
log.Printf("[smtp] ARC - NONE: no ARC headers (original email)")
default:
log.Printf("[smtp] ARC ? UNKNOWN: status=%s", arcValidation.Status)
}
// Determine if this email should be quarantined due to ARC failure
arcFailed := arcValidation.Status == "fail" || arcValidation.Status == "permerror"
if arcFailed {
log.Printf("[smtp] ARC validation failed - will quarantine to spam: %s", arcValidation.Details)
}
// NOTE: Do NOT add ARC headers for inbound original emails.
// ARC headers are only added when FORWARDING/RELAYING messages.
// For original emails with no prior ARC chain, store without modification.
// --- Check for MDN request ---
mdnRequested := parsed.MDNRequestedBy != ""
mdnAddress := parsed.MDNRequestedBy
if mdnRequested {
log.Printf("[smtp] MDN requested for %s to %s", parsed.MessageID, mdnAddress)
}
// --- Store the message for each recipient's account ---
rcptJSON, _ := json.Marshal(rcptTo)
messageID := parsed.MessageID
if messageID == "" {
messageID = fmt.Sprintf("%d.%s@%s", time.Now().UnixNano(), clientIP, s.cfg.Server.Hostname)
}
// --- Check if this is a DSN message (bounce/failure notification) ---
// DSNs arrive addressed to the original sender (not VERP address usually)
// but contain information about which recipients failed
isDSN := parser.IsDSNMessage(parsed.Subject, parsed.Headers, parsed.TextBody)
var dsnReport *parser.DSNReport
if isDSN {
// Pass the full raw message (which includes MIME structure) to ParseDSN
// so it can extract the message/delivery-status part
dsnReport, _ = parser.ParseDSN(string(rawMessage))
if dsnReport != nil {
log.Printf("[smtp] [verp] received DSN: original-id=%s status=%s", dsnReport.OriginalMessageID, dsnReport.Status)
}
}
// Deliver to each recipient
for _, rcpt := range rcptTo {
// Note: DMARC enforcement is now done per-recipient via folder assignment below
// p=reject and p=quarantine both result in messages going to spam folder
// p=none messages go to inbox normally
// Check if this recipient is a VERP bounce address
if s.db.IsVERPBounceAddress(rcpt) {
originalRecipient, err := s.db.DecodeVERP(rcpt)
if err == nil {
log.Printf("[smtp] [verp] detected bounce at VERP address %s for original recipient %s", rcpt, originalRecipient)
// Extract sender from the parsed From header
senderEmail := parsed.From
if senderEmail == "" {
senderEmail = mailFrom
}
// Extract bounce code and type from DSN if available, otherwise use subject heuristics
bounceCode := 0
bounceType := "unknown"
bounceReason := parsed.Subject
if isDSN && dsnReport != nil {
bounceType = parser.ExtractBounceType(dsnReport.Status)
bounceCode = parser.ExtractSMTPCode(dsnReport.DiagnosticCode)
if bounceCode == 0 && len(dsnReport.RecipientsStatus) > 0 {
bounceCode = parser.ExtractSMTPCode(dsnReport.RecipientsStatus[0].DiagnosticCode)
}
bounceReason = dsnReport.DiagnosticCode
if bounceReason == "" {
bounceReason = dsnReport.Status
}
} else {
// Fallback: try to extract SMTP code from message body or subject
if strings.Contains(strings.ToLower(parsed.Subject), "permanent") || strings.Contains(strings.ToLower(parsed.Subject), "failed") {
bounceType = "permanent"
bounceCode = 550 // Generic permanent failure
} else if strings.Contains(strings.ToLower(parsed.Subject), "temporary") || strings.Contains(strings.ToLower(parsed.Subject), "try again") {
bounceType = "temporary"
bounceCode = 421 // Service temporarily unavailable
}
}
// Record the bounce
if err := s.db.RecordVERPBounce(originalRecipient, senderEmail, rcpt, bounceType, bounceCode, bounceReason); err != nil {
log.Printf("[smtp] [verp] error recording bounce: %v", err)
}
// Skip account lookup for VERP addresses (they don't have accounts)
continue
} else {
log.Printf("[smtp] [verp] could not decode VERP address %s: %v", rcpt, err)
}
}
// --- Handle DSN bounces that came to the original sender (not VERP address) ---
if isDSN && dsnReport != nil && len(dsnReport.RecipientsStatus) > 0 {
// Try to match DSN recipients to our sent messages and record VERP bounces
for _, recipStatus := range dsnReport.RecipientsStatus {
if recipStatus.FinalRecipient != "" {
log.Printf("[smtp] [verp] DSN failure for recipient: %s status: %s", recipStatus.FinalRecipient, recipStatus.Status)
// Extract sender - from Return-Path if available, otherwise From header
senderEmail := parsed.From
if senderEmail == "" {
senderEmail = mailFrom
}
bounceType := parser.ExtractBounceType(recipStatus.Status)
bounceCode := parser.ExtractSMTPCode(recipStatus.DiagnosticCode)
bounceReason := recipStatus.DiagnosticCode
if bounceReason == "" {
bounceReason = recipStatus.Status
}
// Record bounce with original recipient from DSN
if err := s.db.RecordVERPBounce(recipStatus.FinalRecipient, senderEmail, rcpt, bounceType, bounceCode, bounceReason); err != nil {
log.Printf("[smtp] [verp] error recording DSN bounce: %v", err)
}
}
}
// DSN messages don't need to be stored as regular mail, skip account lookup
continue
}
// Check if DMARC failed for this domain
if dmarcResult.Result == "fail" {
switch dmarcResult.Policy {
case "reject":
log.Printf("[smtp] DMARC p=reject: accepting but quarantining mail from %s (auth check failed)", fromDomain)
case "quarantine":
log.Printf("[smtp] DMARC p=quarantine: accepting but marking suspicious from %s", fromDomain)
case "none":
log.Printf("[smtp] DMARC p=none: accepting in observation mode from %s", fromDomain)
}
}
// Look up the account for this recipient
account, err := s.db.GetAccountByEmail(rcpt)
if err != nil {
log.Printf("[smtp] account lookup error for %s: %v", rcpt, err)
continue
}
if account == nil || !account.IsActive {
log.Printf("[smtp] no active account for %s, skipping", rcpt)
continue
}
accountID := account.ID
// Determine folder based on auth results
var folderID *int64
// Check if this is an automated message (DSN, MDN, read receipt, etc.)
// These legitimately have SPF "none" since they have no envelope sender
contentType := parsed.Headers.Get("Content-Type")
isAutomatedMessage := strings.Contains(strings.ToLower(contentType), "multipart/report") ||
strings.Contains(strings.ToLower(contentType), "multipart/delivery-status") ||
strings.Contains(strings.ToLower(parsed.Subject), "return receipt") ||
strings.Contains(strings.ToLower(parsed.Subject), "delivery report") ||
parsed.MDNRequestedBy != "" // MDN messages have this set
// Route to inbox ONLY if all three authentications pass
// For automated messages, allow SPF="none" if DKIM and DMARC pass
var allAuthPass bool
if isAutomatedMessage {
// For automated messages: SPF can be "none", but DKIM and DMARC must pass
allAuthPass = dkimResult == "pass" && dmarcResult.Result == "pass"
} else {
// For regular messages: require all three
allAuthPass = spfResult == "pass" && dkimResult == "pass" && dmarcResult.Result == "pass"
}
authFailed := arcFailed || !allAuthPass
if authFailed {
// Any auth failure → spam folder
if arcFailed {
log.Printf("[smtp] ARC failed: quarantining to spam (reason: %s)", arcValidation.Details)
} else {
log.Printf("[smtp] auth check failed - SPF: %s, DKIM: %s, DMARC: %s - quarantining to spam",
spfResult, dkimResult, dmarcResult.Result)
}
spamFolder, err := s.db.GetFolderByType(accountID, "spam")
if err != nil {
log.Printf("[smtp] error getting spam folder: %v", err)
} else if spamFolder != nil {
folderID = &spamFolder.ID
log.Printf("[smtp] message routed to spam folder=%d", spamFolder.ID)
} else {
log.Printf("[smtp] warning: spam folder not found for account %d, using NULL", accountID)
}
} else {
// All auth passed - route to inbox
inboxFolder, err := s.db.GetFolderByType(accountID, "inbox")
if err != nil {
log.Printf("[smtp] error getting inbox folder for account %d: %v", accountID, err)
} else if inboxFolder != nil {
folderID = &inboxFolder.ID
log.Printf("[smtp] all auth passed (SPF: %s, DKIM: %s, DMARC: %s) - assigning to inbox folder=%d",
spfResult, dkimResult, dmarcResult.Result, inboxFolder.ID)
} else {
log.Printf("[smtp] warning: inbox folder not found for account %d, using NULL", accountID)
}
}
msg := &store.Message{
AccountID: accountID,
FolderID: folderID,
MessageID: messageID,
Direction: "inbound",
MailFrom: mailFrom,
RcptTo: string(rcptJSON),
FromAddr: parsed.From,
ToAddr: parsed.To,
CcAddr: parsed.Cc,
ReplyTo: parsed.ReplyTo,
Subject: parsed.Subject,
TextBody: parsed.TextBody,
HTMLBody: parsed.HTMLBody,
RawHeaders: parsed.RawHeaders,
RawMessage: rawMessage,
Size: int64(len(rawMessage)),
HasAttachments: len(parsed.Attachments) > 0,
SPFResult: string(spfResult),
DKIMResult: dkimResult,
DMARCResult: string(dmarcResult.Result),
AuthResults: authResults + "\nARC-Validation: " + arcValidation.Status,
MDNRequested: mdnRequested,
MDNAddress: mdnAddress,
ReceivedAt: time.Now(),
}
// Log what folder_id will be saved
if folderID != nil {
log.Printf("[smtp] saving message subject=%q with folder_id=%d (rcpt=%s)", parsed.Subject, *folderID, rcpt)
} else {
log.Printf("[smtp] saving message subject=%q with folder_id=NULL (rcpt=%s)", parsed.Subject, rcpt)
}
msgID, err := s.db.SaveMessage(msg)
if err != nil {
log.Printf("[smtp] saving message for %s: %v", rcpt, err)
continue
}
// Update folder counts
if folderID != nil {
s.db.UpdateFolderCounts(*folderID)
}
// Save attachments
if len(parsed.Attachments) > 0 {
records, err := parser.SaveAttachments(parsed.Attachments, msgID, s.db.AttachmentsPath())
if err != nil {
log.Printf("[smtp] attachment save error: %v", err)
} else {
for _, rec := range records {
if _, err := s.db.SaveAttachment(rec); err != nil {
log.Printf("[smtp] attachment db save error: %v", err)
}
}
}
}
log.Printf("[smtp] message stored id=%d account=%d msgid=%s from=%s to=%s subject=%s",
msgID, accountID, messageID, mailFrom, rcpt, parsed.Subject)
}
return nil
}
// extractDomain gets the domain part from an email address or From header.
func extractDomain(addr string) string {
// Handle "Name <email@domain>" format
if idx := strings.LastIndex(addr, "<"); idx >= 0 {
addr = addr[idx+1:]
if end := strings.Index(addr, ">"); end >= 0 {
addr = addr[:end]
}
}
parts := strings.SplitN(addr, "@", 2)
if len(parts) == 2 {
return parts[1]
}
return addr
}