From 79f2f3b7ceeae747bc97ef9f3cc5ec779905fb66 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 20 Aug 2026 10:29:58 +0200 Subject: [PATCH 1/6] feat(client): update mailer.go --- api/internal/client/mailer/mailer.go | 45 +++++++----- .../client/mailer/mailer_forward_test.go | 72 +++++++++++++++++++ api/internal/service/processor.go | 8 +-- api/internal/utils/email.go | 6 +- 4 files changed, 108 insertions(+), 23 deletions(-) create mode 100644 api/internal/client/mailer/mailer_forward_test.go diff --git a/api/internal/client/mailer/mailer.go b/api/internal/client/mailer/mailer.go index 0519b905..f5663d28 100644 --- a/api/internal/client/mailer/mailer.go +++ b/api/internal/client/mailer/mailer.go @@ -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 { @@ -206,19 +208,19 @@ 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 } } @@ -226,7 +228,7 @@ func (mailer Mailer) Forward(from string, name string, rcp model.Recipient, data if !settings.RemoveHeader { err = tmpl.ExecuteTemplate(headerHtml, "headerHtml", templateData) if err != nil { - return err + return nil, "", err } } @@ -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) @@ -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) @@ -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 } diff --git a/api/internal/client/mailer/mailer_forward_test.go b/api/internal/client/mailer/mailer_forward_test.go new file mode 100644 index 00000000..2d7342a1 --- /dev/null +++ b/api/internal/client/mailer/mailer_forward_test.go @@ -0,0 +1,72 @@ +package mailer + +import ( + "testing" + + "ivpn.net/email/api/internal/model" +) + +const testForwardRawEmail = "From: Sender \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) + } +} diff --git a/api/internal/service/processor.go b/api/internal/service/processor.go index d51f81ab..63f72949 100644 --- a/api/internal/service/processor.go +++ b/api/internal/service/processor.go @@ -180,7 +180,7 @@ func (s *Service) ProcessMessage(data []byte) error { for _, recipient := range recipients { g.Go(func() error { // Queue Message - err = s.QueueMessage(msg.From, msg.FromName, recipient, data, alias, relayType, settings) + err = s.QueueMessage(msg.From, msg.FromName, to, recipient, data, alias, relayType, settings) if err != nil { return err } @@ -202,17 +202,17 @@ func (s *Service) ProcessMessage(data []byte) error { return g.Wait() } -func (s *Service) QueueMessage(from string, fromName string, rcp model.Recipient, data []byte, alias model.Alias, msgType model.MessageType, settings model.Settings) error { +func (s *Service) QueueMessage(from string, fromName string, to string, rcp model.Recipient, data []byte, alias model.Alias, msgType model.MessageType, settings model.Settings) error { mailer := mailer.New(s.Cfg.SMTPClient) // Queue Forward if msgType == model.Forward { templateData := map[string]any{ - "alias": alias.Name, + "alias": to, "from": from, } generatedFrom := model.GenerateReplyTo(alias.Name, from) - err := mailer.Forward(generatedFrom, fromName, rcp, data, "header.tmpl", templateData, settings, alias) + err := mailer.Forward(generatedFrom, fromName, to, rcp, data, "header.tmpl", templateData, settings, alias) if err != nil { if settings.LogIssues { err := s.ProcessDiagnosticLog(alias, from, rcp.Email, err.Error(), model.DeferredDelivery) diff --git a/api/internal/utils/email.go b/api/internal/utils/email.go index b7e52719..d932129a 100644 --- a/api/internal/utils/email.go +++ b/api/internal/utils/email.go @@ -59,7 +59,9 @@ func EncryptWithPGPInline(plainText string, recipientKey string) (string, error) return string(armored), nil } -func EncryptWithPGPMIME(orig *gomail.Message, fromAddr, fromName, subject, recipientEmail, recipientKey string) (*gomail.Message, error) { +// toHeaderAddr is the address shown in the built message's To: header; it is not +// necessarily the SMTP envelope recipient used for actual delivery. +func EncryptWithPGPMIME(orig *gomail.Message, fromAddr, fromName, subject, toHeaderAddr, recipientKey string) (*gomail.Message, error) { // --- 1) Serialize the original email --- var buf bytes.Buffer if _, err := orig.WriteTo(&buf); err != nil { @@ -123,7 +125,7 @@ func EncryptWithPGPMIME(orig *gomail.Message, fromAddr, fromName, subject, recip // of the correct =?UTF-8?Q?...?=. Passing SetCharset("UTF-8") fixes this. em := gomail.NewRawMessage(gomail.SetCharset("UTF-8")) em.SetAddressHeader("From", fromAddr, fromName) - em.SetHeader("To", recipientEmail) + em.SetHeader("To", toHeaderAddr) em.SetHeader("Subject", DecodeHeaderWithCharset(subject)) em.SetHeader("Date", time.Now().UTC().Format(time.RFC1123Z)) em.SetHeader("Content-Type", fmt.Sprintf("multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"%s\"", boundary)) From 812056f7d7501368dfa7bcac79521c0ed5b40157 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 20 Aug 2026 14:01:52 +0200 Subject: [PATCH 2/6] feat(service): update processor.go --- api/internal/model/msg.go | 41 +++++++----- api/internal/model/msg_test.go | 26 +++++++- api/internal/service/processor.go | 15 +++-- api/internal/utils/email.go | 34 ++++++++++ api/internal/utils/email_test.go | 103 ++++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 21 deletions(-) diff --git a/api/internal/model/msg.go b/api/internal/model/msg.go index 2ec03d12..a4929b5f 100644 --- a/api/internal/model/msg.go +++ b/api/internal/model/msg.go @@ -20,12 +20,13 @@ var ( ) 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 + DeliveredTo string } func ParseMsg(data []byte) (Msg, error) { @@ -52,16 +53,25 @@ func ParseMsg(data []byte) (Msg, error) { to = append(to, address.Address) } + deliveredTo := "" + if raw := strings.TrimSpace(msg.Header.Get("Delivered-To")); raw != "" { + if addr, err := mail.ParseAddress(raw); err == nil { + deliveredTo = addr.Address + } else { + deliveredTo = raw + } + } + 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, DeliveredTo: 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, DeliveredTo: deliveredTo}, err } body := buf.String() msgType := Send @@ -75,17 +85,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, DeliveredTo: 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, + DeliveredTo: deliveredTo, }, nil } diff --git a/api/internal/model/msg_test.go b/api/internal/model/msg_test.go index 38cc4982..36bd35ae 100644 --- a/api/internal/model/msg_test.go +++ b/api/internal/model/msg_test.go @@ -468,6 +468,30 @@ 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, + DeliveredTo: "alias1@mailx.net", + }}, + { + name: "Delivered-To header absent leaves DeliveredTo 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, + DeliveredTo: "", }}} for _, tt := range tests { @@ -484,7 +508,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.DeliveredTo != b.DeliveredTo { return false } if len(a.To) != len(b.To) { diff --git a/api/internal/service/processor.go b/api/internal/service/processor.go index 63f72949..42fced76 100644 --- a/api/internal/service/processor.go +++ b/api/internal/service/processor.go @@ -26,7 +26,7 @@ func (s *Service) ProcessMessage(data []byte) error { return nil } - for _, to := range msg.To { + for _, to := range utils.SelectTargets(msg.To, msg.DeliveredTo) { _, alias, _, err := s.FindRecipients(msg.From, to, msg.Type) if alias.UserID == "" { continue @@ -83,7 +83,7 @@ func (s *Service) ProcessMessage(data []byte) error { var g errgroup.Group - for _, to := range msg.To { + for _, to := range utils.SelectTargets(msg.To, msg.DeliveredTo) { recipients, alias, relayType, err := s.FindRecipients(msg.From, to, msg.Type) if err != nil { log.Println("error processing message:", err, alias.Name) @@ -177,10 +177,15 @@ func (s *Service) ProcessMessage(data []byte) error { } } + toHeader := to + if relayType == model.Forward { + toHeader = utils.CombineForwardTo(to, msg.To, model.GenerateReplyTo) + } + for _, recipient := range recipients { g.Go(func() error { // Queue Message - err = s.QueueMessage(msg.From, msg.FromName, to, recipient, data, alias, relayType, settings) + err = s.QueueMessage(msg.From, msg.FromName, to, toHeader, recipient, data, alias, relayType, settings) if err != nil { return err } @@ -202,7 +207,7 @@ func (s *Service) ProcessMessage(data []byte) error { return g.Wait() } -func (s *Service) QueueMessage(from string, fromName string, to string, rcp model.Recipient, data []byte, alias model.Alias, msgType model.MessageType, settings model.Settings) error { +func (s *Service) QueueMessage(from string, fromName string, to string, toHeader string, rcp model.Recipient, data []byte, alias model.Alias, msgType model.MessageType, settings model.Settings) error { mailer := mailer.New(s.Cfg.SMTPClient) // Queue Forward @@ -212,7 +217,7 @@ func (s *Service) QueueMessage(from string, fromName string, to string, rcp mode "from": from, } generatedFrom := model.GenerateReplyTo(alias.Name, from) - err := mailer.Forward(generatedFrom, fromName, to, rcp, data, "header.tmpl", templateData, settings, alias) + err := mailer.Forward(generatedFrom, fromName, toHeader, rcp, data, "header.tmpl", templateData, settings, alias) if err != nil { if settings.LogIssues { err := s.ProcessDiagnosticLog(alias, from, rcp.Email, err.Error(), model.DeferredDelivery) diff --git a/api/internal/utils/email.go b/api/internal/utils/email.go index d932129a..9d1e0e41 100644 --- a/api/internal/utils/email.go +++ b/api/internal/utils/email.go @@ -23,6 +23,40 @@ func RemoveHeader(text string) string { return re.ReplaceAllString(text, "") } +// SelectTargets scopes processing to the single To address matching this +// invocation's envelope recipient (Postfix pipes one copy per recipient), so +// the same message doesn't get reprocessed for every address in To. Falls +// back to all of to when deliveredTo is empty or doesn't match any of them. +func SelectTargets(to []string, deliveredTo string) []string { + if deliveredTo == "" { + return to + } + + for _, addr := range to { + if strings.EqualFold(addr, deliveredTo) { + return []string{addr} + } + } + + return to +} + +// CombineForwardTo builds the forwarded message's To header, noting any other +// aliases the original message was also addressed to so recipients shared by +// multiple aliases only get one email instead of one per alias. encode embeds +// each other alias into the primary one (e.g. model.GenerateReplyTo). +func CombineForwardTo(primary string, all []string, encode func(alias, to string) string) string { + header := primary + for _, other := range all { + if strings.EqualFold(other, primary) { + continue + } + header += ", " + encode(primary, other) + } + + return header +} + func RemoveHtmlHeader(html string) string { // Relaxed regex: match any containing "This email was sent to" and ending at
re := regexp.MustCompile(`(?is)]*>.*?This email was sent to.*?`) diff --git a/api/internal/utils/email_test.go b/api/internal/utils/email_test.go index 6aa475b5..b20957a2 100644 --- a/api/internal/utils/email_test.go +++ b/api/internal/utils/email_test.go @@ -396,3 +396,106 @@ func TestRawMessageWithUTF8Charset_CyrillicHeaders(t *testing.T) { } } } + +func TestSelectTargets(t *testing.T) { + tests := []struct { + name string + to []string + deliveredTo string + want []string + }{ + { + name: "no Delivered-To returns all To entries", + to: []string{"alias1@example.com", "alias2@example.com"}, + deliveredTo: "", + want: []string{"alias1@example.com", "alias2@example.com"}, + }, + { + name: "Delivered-To matches one of several To entries", + to: []string{"alias1@example.com", "alias2@example.com"}, + deliveredTo: "alias2@example.com", + want: []string{"alias2@example.com"}, + }, + { + name: "Delivered-To match is case-insensitive", + to: []string{"Alias1@example.com", "alias2@example.com"}, + deliveredTo: "alias1@example.com", + want: []string{"Alias1@example.com"}, + }, + { + name: "Delivered-To not found in To falls back to all entries", + to: []string{"alias1@example.com", "alias2@example.com"}, + deliveredTo: "unrelated@example.com", + want: []string{"alias1@example.com", "alias2@example.com"}, + }, + { + name: "single To entry unaffected by Delivered-To", + to: []string{"alias1@example.com"}, + deliveredTo: "alias1@example.com", + want: []string{"alias1@example.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SelectTargets(tt.to, tt.deliveredTo) + if len(got) != len(tt.want) { + t.Fatalf("SelectTargets() = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("SelectTargets() = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestCombineForwardTo(t *testing.T) { + // Mirrors model.GenerateReplyTo without importing the model package (which + // itself imports utils, so importing model here would create a cycle). + generateReplyTo := func(alias string, to string) string { + replaced := strings.Replace(to, "@", "=", 1) + return strings.Replace(alias, "@", "+"+replaced+"@", 1) + } + + tests := []struct { + name string + primary string + all []string + want string + }{ + { + name: "no other aliases leaves header unchanged", + primary: "alias1@example.com", + all: []string{"alias1@example.com"}, + want: "alias1@example.com", + }, + { + name: "other alias sharing the same domain is reply-encoded", + primary: "alias1@example.com", + all: []string{"alias1@example.com", "alias2@example.com"}, + want: "alias1@example.com, alias1+alias2=example.com@example.com", + }, + { + name: "primary alias2 mirrors the encoding symmetrically", + primary: "alias2@example.com", + all: []string{"alias1@example.com", "alias2@example.com"}, + want: "alias2@example.com, alias2+alias1=example.com@example.com", + }, + { + name: "multiple other aliases are all appended", + primary: "alias1@example.com", + all: []string{"alias1@example.com", "alias2@example.com", "alias3@example.com"}, + want: "alias1@example.com, alias1+alias2=example.com@example.com, alias1+alias3=example.com@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CombineForwardTo(tt.primary, tt.all, generateReplyTo); got != tt.want { + t.Errorf("CombineForwardTo() = %q, want %q", got, tt.want) + } + }) + } +} From b2597c0356956365c682402d261a5858311f5085 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 20 Aug 2026 14:14:39 +0200 Subject: [PATCH 3/6] feat(utils): update email.go --- api/internal/utils/email.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/internal/utils/email.go b/api/internal/utils/email.go index 9d1e0e41..79a415e0 100644 --- a/api/internal/utils/email.go +++ b/api/internal/utils/email.go @@ -46,15 +46,16 @@ func SelectTargets(to []string, deliveredTo string) []string { // multiple aliases only get one email instead of one per alias. encode embeds // each other alias into the primary one (e.g. model.GenerateReplyTo). func CombineForwardTo(primary string, all []string, encode func(alias, to string) string) string { - header := primary + var header strings.Builder + header.WriteString(primary) for _, other := range all { if strings.EqualFold(other, primary) { continue } - header += ", " + encode(primary, other) + header.WriteString(", " + encode(primary, other)) } - return header + return header.String() } func RemoveHtmlHeader(html string) string { From dca15d489835004dfc6b0ba2d2b39b78ae3a032d Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 20 Aug 2026 15:12:30 +0200 Subject: [PATCH 4/6] feat(model): update msg.go --- api/internal/model/msg.go | 51 ++++++++++++++++---------- api/internal/model/msg_test.go | 60 ++++++++++++++++++++++--------- api/internal/service/processor.go | 4 +-- 3 files changed, 79 insertions(+), 36 deletions(-) diff --git a/api/internal/model/msg.go b/api/internal/model/msg.go index a4929b5f..9cdef9aa 100644 --- a/api/internal/model/msg.go +++ b/api/internal/model/msg.go @@ -17,16 +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<>;,]+)>?`) ) type Msg struct { - From string - FromName string - To []string - Subject string - Body string - Type MessageType - DeliveredTo string + From string + FromName string + To []string + Subject string + Body string + Type MessageType + EnvelopeRecipient string } func ParseMsg(data []byte) (Msg, error) { @@ -54,24 +55,38 @@ func ParseMsg(data []byte) (Msg, error) { } deliveredTo := "" - if raw := strings.TrimSpace(msg.Header.Get("Delivered-To")); raw != "" { + 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
" 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, DeliveredTo: deliveredTo}, 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, DeliveredTo: deliveredTo}, err + return Msg{To: to, EnvelopeRecipient: deliveredTo}, err } body := buf.String() msgType := Send @@ -85,18 +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, DeliveredTo: deliveredTo}, ErrExtractOriginalFrom + return Msg{To: to, EnvelopeRecipient: deliveredTo}, ErrExtractOriginalFrom } } return Msg{ - From: fromAddress, - FromName: from.Name, - To: to, - Subject: subject, - Body: body, - Type: msgType, - DeliveredTo: deliveredTo, + From: fromAddress, + FromName: from.Name, + To: to, + Subject: subject, + Body: body, + Type: msgType, + EnvelopeRecipient: deliveredTo, }, nil } diff --git a/api/internal/model/msg_test.go b/api/internal/model/msg_test.go index 36bd35ae..9490d945 100644 --- a/api/internal/model/msg_test.go +++ b/api/internal/model/msg_test.go @@ -473,25 +473,53 @@ func TestParseMsg(t *testing.T) { 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, - DeliveredTo: "alias1@mailx.net", + 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 header absent leaves DeliveredTo empty", + 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
" 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 ; 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, - DeliveredTo: "", + From: "sender@example.com", + FromName: "", + To: []string{"alias1@mailx.net"}, + Subject: "Test", + Body: "Body", + Type: Send, + EnvelopeRecipient: "", }}} for _, tt := range tests { @@ -508,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 || a.DeliveredTo != b.DeliveredTo { + 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) { diff --git a/api/internal/service/processor.go b/api/internal/service/processor.go index 42fced76..ece975f4 100644 --- a/api/internal/service/processor.go +++ b/api/internal/service/processor.go @@ -26,7 +26,7 @@ func (s *Service) ProcessMessage(data []byte) error { return nil } - for _, to := range utils.SelectTargets(msg.To, msg.DeliveredTo) { + for _, to := range utils.SelectTargets(msg.To, msg.EnvelopeRecipient) { _, alias, _, err := s.FindRecipients(msg.From, to, msg.Type) if alias.UserID == "" { continue @@ -83,7 +83,7 @@ func (s *Service) ProcessMessage(data []byte) error { var g errgroup.Group - for _, to := range utils.SelectTargets(msg.To, msg.DeliveredTo) { + for _, to := range utils.SelectTargets(msg.To, msg.EnvelopeRecipient) { recipients, alias, relayType, err := s.FindRecipients(msg.From, to, msg.Type) if err != nil { log.Println("error processing message:", err, alias.Name) From b60d55145e347105b521cd7a52e4628b6a48bc37 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 20 Aug 2026 15:35:44 +0200 Subject: [PATCH 5/6] feat(api): update processor.go --- api/internal/service/processor.go | 12 +++++++++--- api/internal/transport/api/processor.go | 5 +++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/api/internal/service/processor.go b/api/internal/service/processor.go index ece975f4..ca8ee605 100644 --- a/api/internal/service/processor.go +++ b/api/internal/service/processor.go @@ -18,15 +18,21 @@ var ( ErrInactiveRecipient = errors.New("The recipient is inactive.") ) -func (s *Service) ProcessMessage(data []byte) error { +func (s *Service) ProcessMessage(data []byte, envelopeRecipient string) error { msg, parseErr := model.ParseMsg(data) + if envelopeRecipient == "" { + // Caller didn't pass the actual envelope recipient (e.g. Postfix pipe not + // yet updated to pass it); fall back to guessing it from headers. + envelopeRecipient = msg.EnvelopeRecipient + } + if parseErr != nil { if errors.Is(parseErr, model.ErrExtractOriginalFrom) { // Fail silently so bounce messages are not kept in postfix queue return nil } - for _, to := range utils.SelectTargets(msg.To, msg.EnvelopeRecipient) { + for _, to := range utils.SelectTargets(msg.To, envelopeRecipient) { _, alias, _, err := s.FindRecipients(msg.From, to, msg.Type) if alias.UserID == "" { continue @@ -83,7 +89,7 @@ func (s *Service) ProcessMessage(data []byte) error { var g errgroup.Group - for _, to := range utils.SelectTargets(msg.To, msg.EnvelopeRecipient) { + for _, to := range utils.SelectTargets(msg.To, envelopeRecipient) { recipients, alias, relayType, err := s.FindRecipients(msg.From, to, msg.Type) if err != nil { log.Println("error processing message:", err, alias.Name) diff --git a/api/internal/transport/api/processor.go b/api/internal/transport/api/processor.go index af461c3a..92f24f98 100644 --- a/api/internal/transport/api/processor.go +++ b/api/internal/transport/api/processor.go @@ -5,7 +5,7 @@ import ( ) type ProcessorService interface { - ProcessMessage([]byte) error + ProcessMessage(data []byte, envelopeRecipient string) error } // @Summary Email handler @@ -14,10 +14,11 @@ type ProcessorService interface { // @Accept json // @Produce json // @Param email body string true "Email body" +// @Param recipient query string false "Envelope recipient for this specific delivery (Postfix ${recipient})" // @Success 200 {string} string "OK" // @Router /email [post] func (h *Handler) HandleEmail(c *fiber.Ctx) error { - err := h.Service.ProcessMessage(c.Body()) + err := h.Service.ProcessMessage(c.Body(), c.Query("recipient")) if err != nil { // TEMPORARY failure → Postfix should retry return c.Status(fiber.StatusServiceUnavailable).SendString("temporary failure") From 4c335070da25eb69a2ec5654012f1fd78dfd5cf1 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 20 Aug 2026 15:36:06 +0200 Subject: [PATCH 6/6] feat(mailserver): update user-patches.sh.sample --- mailserver/config/user-patches.sh.sample | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mailserver/config/user-patches.sh.sample b/mailserver/config/user-patches.sh.sample index c8de746e..05f63fee 100644 --- a/mailserver/config/user-patches.sh.sample +++ b/mailserver/config/user-patches.sh.sample @@ -14,9 +14,12 @@ cat > /usr/local/bin/curl-email.sh <> /var/log/mail/curl-email.log body=\$(mktemp /tmp/curl-email.XXXXXX) +# Percent-encode '+' so the API's query parser doesn't decode it back to a +# space (aliases commonly use '+', e.g. plus-tagged/reply-encoded addresses). +encoded_recipient=\$(printf '%s' "\$2" | sed 's/+/%2B/g') http_code=\$(curl --silent --data-binary @- \\ -H "Authorization: Bearer $PSK" \\ - -X POST $API_URL/v1/email \\ + -X POST "$API_URL/v1/email?recipient=\$encoded_recipient" \\ -o "\$body" -w "%{http_code}") resp_body=\$(cat "\$body") rm -f "\$body"