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
18 changes: 18 additions & 0 deletions cmd/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,24 @@ func (a *App) UpdateSettings(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("settings.errorNoSMTP"))
}

// Normalize from_addresses (duplicates across servers are allowed for round-robin).
for i, s := range set.SMTP {
if !s.Enabled {
continue
}

// Normalize from addresses.
normalized := make([]string, 0, len(s.FromAddresses))
for _, addr := range s.FromAddresses {
addr = strings.ToLower(strings.TrimSpace(addr))
if addr == "" {
continue
}
normalized = append(normalized, addr)
}
set.SMTP[i].FromAddresses = normalized
}

// Always remove the trailing slash from the app root URL.
set.AppRootURL = strings.TrimRight(set.AppRootURL, "/")

Expand Down
12 changes: 12 additions & 0 deletions frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@ export default Vue.extend({
} else {
form.smtp[i].email_headers = [];
}

// Convert comma-separated from_addresses string to array.
if (form.smtp[i].strFromAddresses) {
form.smtp[i].from_addresses = form.smtp[i].strFromAddresses
.split(',')
.map((v) => v.trim())
.filter((v) => v !== '');
} else {
form.smtp[i].from_addresses = [];
}
}

// Bounces boxes.
Expand Down Expand Up @@ -222,6 +232,8 @@ export default Vue.extend({
// Serialize the `email_headers` array map to display on the form.
for (let i = 0; i < d.smtp.length; i += 1) {
d.smtp[i].strEmailHeaders = JSON.stringify(d.smtp[i].email_headers, null, 4);
// Convert from_addresses array to comma-separated string.
d.smtp[i].strFromAddresses = (d.smtp[i].from_addresses || []).join(', ');
}

// Domain blocklist array to multi-line string.
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/views/settings/smtp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@
<b-input v-model="item.name" name="name" placeholder="email-primary" :maxlength="100" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('settings.smtp.fromAddresses')" label-position="on-border"
:message="$t('settings.smtp.fromAddressesHelp')">
<b-input v-model="item.strFromAddresses" name="from_addresses"
placeholder="@example.com, user@domain.com, anothersite.com" />
</b-field>
</div>
</div>

<div class="columns">
Expand Down Expand Up @@ -273,6 +280,7 @@ export default Vue.extend({
username: '',
password: '',
email_headers: [],
from_addresses: [],
max_conns: 10,
max_msg_retries: 2,
idle_timeout: '15s',
Expand Down
2 changes: 2 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,8 @@
"settings.smtp.customHeaders": "Custom headers",
"settings.smtp.customHeadersHelp": "Optional array of e-mail headers to include in all messages sent from this server. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Enabled",
"settings.smtp.fromAddresses": "From addresses / domains",
"settings.smtp.fromAddressesHelp": "Optional comma-separated list of email addresses or domains. Supports full emails (user@example.com), domains with @ (@example.com), or domains without @ (example.com). Emails from these addresses/domains will be routed through this SMTP server. Multiple servers can specify the same address/domain for round-robin load balancing.",
"settings.smtp.heloHost": "HELO hostname",
"settings.smtp.heloHostHelp": "Optional. Some SMTP servers require a FQDN in the hostname. By default, HELLOs go with `localhost`. Set this if a custom hostname should be used.",
"settings.smtp.name": "SMTP",
Expand Down
80 changes: 71 additions & 9 deletions internal/messenger/email/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/tls"
"fmt"
"math/rand"
"net/mail"
"net/smtp"
"net/textproto"
"strings"
Expand All @@ -30,6 +31,7 @@ type Server struct {
TLSType string `json:"tls_type"`
TLSSkipVerify bool `json:"tls_skip_verify"`
EmailHeaders map[string]string `json:"email_headers"`
FromAddresses []string `json:"from_addresses"`

// Rest of the options are embedded directly from the smtppool lib.
// The JSON tag is for config unmarshal to work.
Expand All @@ -41,17 +43,19 @@ type Server struct {

// Emailer is the SMTP e-mail messenger.
type Emailer struct {
servers []*Server
name string
servers []*Server
name string
fromAddrToSrv map[string][]*Server
}

// New returns an SMTP e-mail Messenger backend with the given SMTP servers.
// Group indicates whether the messenger represents a group of SMTP servers (1 or more)
// that are used as a round-robin pool, or a single server.
func New(name string, servers ...Server) (*Emailer, error) {
e := &Emailer{
servers: make([]*Server, 0, len(servers)),
name: name,
servers: make([]*Server, 0, len(servers)),
name: name,
fromAddrToSrv: make(map[string][]*Server),
}

for _, srv := range servers {
Expand Down Expand Up @@ -97,6 +101,22 @@ func New(name string, servers ...Server) (*Emailer, error) {

s.pool = pool
e.servers = append(e.servers, &s)

// Map from addresses to this server.
// Track which keys are already mapped to this server to avoid duplicates.
seenForServer := make(map[string]bool)
for _, addr := range s.FromAddresses {
key := strings.ToLower(strings.TrimSpace(addr))
// Skip empty keys and duplicates for this server.
if key == "" {
continue
}
if seenForServer[key] {
continue
}
seenForServer[key] = true
e.fromAddrToSrv[key] = append(e.fromAddrToSrv[key], &s)
}
}

return e, nil
Expand All @@ -109,16 +129,58 @@ func (e *Emailer) Name() string {

// Push pushes a message to the server.
func (e *Emailer) Push(m models.Message) error {
// If there are more than one SMTP servers, send to a random
// one from the list.
// Check if there's a specific SMTP server mapped to the from address.
var (
ln = len(e.servers)
srv *Server
)
if ln > 1 {
srv = e.servers[rand.Intn(ln)]

// Extract the email address from the From field using RFC 5322 parsing.
fromAddr := m.From
if addr, err := mail.ParseAddress(m.From); err == nil {
fromAddr = addr.Address
}
// If parsing fails, fall back to the raw From value (for backward compatibility).
fromAddr = strings.ToLower(strings.TrimSpace(fromAddr))

// Check if there's a server mapped to this from address.
// First try exact match, then try domain match.
var matchedServers []*Server
if servers, ok := e.fromAddrToSrv[fromAddr]; ok {
matchedServers = servers
} else {
srv = e.servers[0]
// Extract domain from email address for domain matching.
if atIdx := strings.Index(fromAddr, "@"); atIdx >= 0 {
domain := fromAddr[atIdx:] // includes @ symbol
if servers, ok := e.fromAddrToSrv[domain]; ok {
matchedServers = servers
} else {
// Try without @ symbol.
domainOnly := fromAddr[atIdx+1:]
if servers, ok := e.fromAddrToSrv[domainOnly]; ok {
matchedServers = servers
}
}
}
}

// If we found matching servers, pick one randomly (round-robin).
if len(matchedServers) > 0 {
if len(matchedServers) > 1 {
srv = matchedServers[rand.Intn(len(matchedServers))]
} else {
srv = matchedServers[0]
}
}

// If no match found, fall back to load balancing.
if srv == nil {
if ln > 1 {
// If there are more than one SMTP servers, send to a random one from the list.
srv = e.servers[rand.Intn(ln)]
} else {
srv = e.servers[0]
}
}

// Are there attachments?
Expand Down
1 change: 1 addition & 0 deletions models/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ type Settings struct {
WaitTimeout string `json:"wait_timeout"`
TLSType string `json:"tls_type"`
TLSSkipVerify bool `json:"tls_skip_verify"`
FromAddresses []string `json:"from_addresses"`
} `json:"smtp"`

Messengers []struct {
Expand Down