Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 28 additions & 17 deletions api/internal/client/mailer/mailer.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ func (mailer Mailer) Reply(from string, name string, rcp model.Recipient, data [
return nil
}

func (mailer Mailer) Forward(from string, name string, rcp model.Recipient, data []byte, templateFile string, templateData any, settings model.Settings, alias model.Alias) error {
// buildForwardMessage builds the outgoing forward message without dialing the network,
// so the To: header logic can be unit tested in isolation.
func (mailer Mailer) buildForwardMessage(from string, name string, to string, rcp model.Recipient, data []byte, templateFile string, templateData any, settings model.Settings, alias model.Alias) (*gomail.Message, string, error) {
// Preprocess email data to decode RFC 2047 encoded headers
processedData, err := utils.PreprocessEmailData(data)
if err != nil {
Expand All @@ -206,27 +208,27 @@ func (mailer Mailer) Forward(from string, name string, rcp model.Recipient, data
reader := bytes.NewReader(processedData)
email, err := parser.Parse(reader)
if err != nil {
return err
return nil, "", err
}

tmpl, err := template.New("email").ParseFS(templateFS, "templates/"+templateFile)
if err != nil {
return err
return nil, "", err
}

header := new(bytes.Buffer)
if !settings.RemoveHeader {
err = tmpl.ExecuteTemplate(header, "header", templateData)
if err != nil {
return err
return nil, "", err
}
}

headerHtml := new(bytes.Buffer)
if !settings.RemoveHeader {
err = tmpl.ExecuteTemplate(headerHtml, "headerHtml", templateData)
if err != nil {
return err
return nil, "", err
}
}

Expand All @@ -242,7 +244,7 @@ func (mailer Mailer) Forward(from string, name string, rcp model.Recipient, data

m := gomail.NewMessage()
m.SetAddressHeader("From", from, name)
m.SetHeader("To", rcp.Email)
m.SetHeader("To", to)
m.SetHeader("Subject", decodedSubject)
m.SetBody("text/plain", header.String()+email.Text)

Expand Down Expand Up @@ -297,7 +299,7 @@ func (mailer Mailer) Forward(from string, name string, rcp model.Recipient, data
if rcp.PGPEnabled && rcp.PGPKey != "" && rcp.PGPInline {
armored, err := utils.EncryptWithPGPInline(email.Text, rcp.PGPKey)
if err != nil {
return err
return nil, "", err
}
m.SetHeader("Content-Type", "text/plain")
m.SetBody("text/plain", armored)
Expand Down Expand Up @@ -329,26 +331,35 @@ func (mailer Mailer) Forward(from string, name string, rcp model.Recipient, data

// PGP/MIME encryption
if rcp.PGPEnabled && rcp.PGPKey != "" && !rcp.PGPInline {
em, err := utils.EncryptWithPGPMIME(m, from, name, decodedSubject, rcp.Email, rcp.PGPKey)
em, err := utils.EncryptWithPGPMIME(m, from, name, decodedSubject, to, rcp.PGPKey)
if err != nil {
return err
return nil, "", err
}

err = mailer.dialer.DialAndSend(em)
if err != nil {
return err
}
return em, string(email.Headers.MessageID), nil
}

log.Printf("PGP/MIME email forward sent successfully, %s", email.Headers.MessageID)
return nil
return m, string(email.Headers.MessageID), nil
}

func (mailer Mailer) Forward(from string, name string, to string, rcp model.Recipient, data []byte, templateFile string, templateData any, settings model.Settings, alias model.Alias) error {
m, messageID, err := mailer.buildForwardMessage(from, name, to, rcp, data, templateFile, templateData, settings, alias)
if err != nil {
return err
}

err = mailer.dialer.DialAndSend(m)
sc, err := mailer.dialer.Dial()
if err != nil {
return err
}
defer sc.Close()

// Envelope recipient stays the real mailbox even though To: shows the alias.
if err := sc.Send(from, []string{rcp.Email}, m); err != nil {
return err
}

log.Printf("Email forward sent successfully, %s", email.Headers.MessageID)
log.Printf("Email forward sent successfully, %s", messageID)
return nil
}

Expand Down
72 changes: 72 additions & 0 deletions api/internal/client/mailer/mailer_forward_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package mailer

import (
"testing"

"ivpn.net/email/api/internal/model"
)

const testForwardRawEmail = "From: Sender <sender@example.com>\r\n" +
"To: original-to@example.com\r\n" +
"Subject: Hello\r\n" +
"\r\n" +
"Test body content\r\n"

func TestBuildForwardMessage_ToHeaderUsesOriginalAliasAddress(t *testing.T) {
m := Mailer{}
rcp := model.Recipient{Email: "real.recipient@domain.com"}
alias := model.Alias{BaseModel: model.BaseModel{ID: "alias-1"}, Name: "myalias@mailx.net"}
settings := model.Settings{}
to := "myalias@mailx.net"
templateData := map[string]any{"alias": to, "from": "sender@example.com"}

msg, _, err := m.buildForwardMessage(
"sender@example.com", "Sender", to, rcp,
[]byte(testForwardRawEmail), "header.tmpl", templateData, settings, alias,
)
if err != nil {
t.Fatalf("buildForwardMessage() error = %v", err)
}

got := msg.GetHeader("To")
if len(got) != 1 || got[0] != to {
t.Errorf("To header = %v, want [%s]", got, to)
}
for _, v := range got {
if v == rcp.Email {
t.Errorf("To header must not contain the real recipient mailbox %q", rcp.Email)
}
}

// The real mailbox is still preserved in the diagnostic header.
originalTo := msg.GetHeader("X-Mailx-Original-To")
if len(originalTo) != 1 || originalTo[0] != rcp.Email {
t.Errorf("X-Mailx-Original-To = %v, want [%s]", originalTo, rcp.Email)
}
}

func TestBuildForwardMessage_ToHeaderUsesLiteralTaggedAddress(t *testing.T) {
m := Mailer{}
rcp := model.Recipient{Email: "real.recipient@domain.com"}
// alias.Name is the canonical, tag-stripped alias; it can even be a literal
// wildcard pattern (e.g. "*+news@customdomain.com") for wildcard/catch-all
// aliases, so the To: header must use the concrete address the sender used
// instead ("to"), not alias.Name.
alias := model.Alias{BaseModel: model.BaseModel{ID: "alias-1"}, Name: "myalias@mailx.net"}
settings := model.Settings{}
to := "myalias+shop@mailx.net"
templateData := map[string]any{"alias": to, "from": "sender@example.com"}

msg, _, err := m.buildForwardMessage(
"sender@example.com", "Sender", to, rcp,
[]byte(testForwardRawEmail), "header.tmpl", templateData, settings, alias,
)
if err != nil {
t.Fatalf("buildForwardMessage() error = %v", err)
}

got := msg.GetHeader("To")
if len(got) != 1 || got[0] != to {
t.Errorf("To header = %v, want [%s]", got, to)
}
}
56 changes: 41 additions & 15 deletions api/internal/model/msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ import (
var (
ErrExtractOriginalFrom = fmt.Errorf("error extracting original From from bounce")
replySubjectRE = regexp.MustCompile(`(?i)^\s*(re|aw|antw|sv|rif|回复|回覆)\s*:\s*`)
receivedForRE = regexp.MustCompile(`(?i)\bfor\s+<?([^\s<>;,]+@[^\s<>;,]+)>?`)
)

type Msg struct {
From string
FromName string
To []string
Subject string
Body string
Type MessageType
From string
FromName string
To []string
Subject string
Body string
Type MessageType
EnvelopeRecipient string
}

func ParseMsg(data []byte) (Msg, error) {
Expand All @@ -52,16 +54,39 @@ func ParseMsg(data []byte) (Msg, error) {
to = append(to, address.Address)
}

deliveredTo := ""
for _, h := range []string{"Delivered-To", "X-Original-To"} {
raw := strings.TrimSpace(msg.Header.Get(h))
if raw == "" {
continue
}

if addr, err := mail.ParseAddress(raw); err == nil {
deliveredTo = addr.Address
} else {
deliveredTo = raw
}
break
}
if deliveredTo == "" {
// Some pipe transports don't stamp Delivered-To/X-Original-To; fall back to
// the "for <address>" clause Postfix adds to its own (topmost) Received
// header when that hop had a single envelope recipient.
if m := receivedForRE.FindStringSubmatch(msg.Header.Get("Received")); len(m) == 2 {
deliveredTo = m[1]
}
}

from, err := mail.ParseAddress(utils.DecodeHeaderWithCharset(msg.Header.Get("From")))
if err != nil {
return Msg{To: to}, fmt.Errorf("error parsing From header: %w", err)
return Msg{To: to, EnvelopeRecipient: deliveredTo}, fmt.Errorf("error parsing From header: %w", err)
}
fromAddress := from.Address

buf := new(bytes.Buffer)
_, err = buf.ReadFrom(msg.Body)
if err != nil {
return Msg{To: to}, err
return Msg{To: to, EnvelopeRecipient: deliveredTo}, err
}
body := buf.String()
msgType := Send
Expand All @@ -75,17 +100,18 @@ func ParseMsg(data []byte) (Msg, error) {
fromAddress, err = ExtractOriginalFrom(processedData)
if err != nil {
log.Println("error extracting original From from bounce:", err)
return Msg{To: to}, ErrExtractOriginalFrom
return Msg{To: to, EnvelopeRecipient: deliveredTo}, ErrExtractOriginalFrom
}
}

return Msg{
From: fromAddress,
FromName: from.Name,
To: to,
Subject: subject,
Body: body,
Type: msgType,
From: fromAddress,
FromName: from.Name,
To: to,
Subject: subject,
Body: body,
Type: msgType,
EnvelopeRecipient: deliveredTo,
}, nil
}

Expand Down
54 changes: 53 additions & 1 deletion api/internal/model/msg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,58 @@ func TestParseMsg(t *testing.T) {
Subject: "Жыр",
Body: "Body",
Type: Send,
}},
{
name: "Delivered-To header present is captured",
data: "Delivered-To: alias1@mailx.net\r\nFrom: sender@example.com\r\nTo: alias1@mailx.net, alias2@mailx.net\r\nSubject: Test\r\n\r\nBody",
want: Msg{
From: "sender@example.com",
FromName: "",
To: []string{"alias1@mailx.net", "alias2@mailx.net"},
Subject: "Test",
Body: "Body",
Type: Send,
EnvelopeRecipient: "alias1@mailx.net",
}},
{
name: "X-Original-To header used when Delivered-To is absent",
data: "X-Original-To: alias2@mailx.net\r\nFrom: sender@example.com\r\nTo: alias1@mailx.net, alias2@mailx.net\r\nSubject: Test\r\n\r\nBody",
want: Msg{
From: "sender@example.com",
FromName: "",
To: []string{"alias1@mailx.net", "alias2@mailx.net"},
Subject: "Test",
Body: "Body",
Type: Send,
EnvelopeRecipient: "alias2@mailx.net",
}},
{
// Regression: some pipe transports don't stamp Delivered-To/X-Original-To
// (observed in production); fall back to the "for <address>" clause on
// the topmost Received header, which Postfix adds when that hop had a
// single envelope recipient.
name: "Received header 'for' clause used as fallback",
data: "Received: from mail-ej1-f48.google.com by mail.irelay.app (Postfix) with ESMTPS id 4CD3F2C88B for <alias1@mailx.net>; Thu, 20 Aug 2026 14:55:06 +0200 (CEST)\r\nFrom: sender@example.com\r\nTo: alias1@mailx.net, alias2@mailx.net\r\nSubject: Test\r\n\r\nBody",
want: Msg{
From: "sender@example.com",
FromName: "",
To: []string{"alias1@mailx.net", "alias2@mailx.net"},
Subject: "Test",
Body: "Body",
Type: Send,
EnvelopeRecipient: "alias1@mailx.net",
}},
{
name: "Delivered-To, X-Original-To and Received headers all absent leaves EnvelopeRecipient empty",
data: "From: sender@example.com\r\nTo: alias1@mailx.net\r\nSubject: Test\r\n\r\nBody",
want: Msg{
From: "sender@example.com",
FromName: "",
To: []string{"alias1@mailx.net"},
Subject: "Test",
Body: "Body",
Type: Send,
EnvelopeRecipient: "",
}}}

for _, tt := range tests {
Expand All @@ -484,7 +536,7 @@ func TestParseMsg(t *testing.T) {
}

func compareMessages(a, b Msg) bool {
if a.From != b.From || a.FromName != b.FromName || a.Subject != b.Subject || a.Body != b.Body || a.Type != b.Type {
if a.From != b.From || a.FromName != b.FromName || a.Subject != b.Subject || a.Body != b.Body || a.Type != b.Type || a.EnvelopeRecipient != b.EnvelopeRecipient {
return false
}
if len(a.To) != len(b.To) {
Expand Down
Loading
Loading