Skip to content

Commit 00e113f

Browse files
committed
feat: support plaintext SMTP relays (WI-1106)
1 parent 4d30a9c commit 00e113f

10 files changed

Lines changed: 97 additions & 68 deletions

File tree

frontend/src/lib/features/channels/ChannelSMTPConfig.svelte

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,16 @@
9090
}
9191
9292
export function getConfig() {
93+
const plaintext = formData.encryption === 'none';
9394
return {
9495
smtp_host: formData.host,
9596
smtp_port: formData.port || 587,
96-
smtp_username: formData.username || '',
97-
smtp_password: formData.password || undefined,
97+
smtp_username: plaintext ? '' : formData.username || '',
98+
smtp_password: plaintext ? '' : formData.password || undefined,
9899
smtp_from_email: formData.from_email,
99100
smtp_from_name: formData.from_name || '',
100101
smtp_encryption: formData.encryption || 'tls',
101-
smtp_skip_tls_verify: formData.skip_tls_verify || false
102+
smtp_skip_tls_verify: plaintext ? false : formData.skip_tls_verify || false
102103
};
103104
}
104105
@@ -126,37 +127,54 @@
126127
127128
<div>
128129
<Label color="default" class="mb-2">{t('channel.smtpEncryption')}</Label>
129-
<Select bind:value={formData.encryption} options={[{ value: 'tls', label: 'TLS (Port 587)' }, { value: 'ssl', label: 'SSL (Port 465)' }]} />
130+
<Select
131+
id="smtp-encryption"
132+
bind:value={formData.encryption}
133+
ariaLabel={t('channel.smtpEncryption')}
134+
options={[
135+
{ value: 'tls', label: 'STARTTLS (Port 587)' },
136+
{ value: 'ssl', label: 'Implicit TLS (Port 465)' },
137+
{ value: 'none', label: t('channel.noEncryption') }
138+
]}
139+
/>
130140
</div>
131141
132-
<div class="flex items-start justify-between gap-4">
133-
<div>
134-
<div class="text-sm font-medium" style="color: var(--ds-text);">
135-
{t('channel.smtpSkipTlsVerify')}
142+
{#if formData.encryption === 'none'}
143+
<div data-testid="smtp-plaintext-warning">
144+
<AlertBox variant="warning" message={t('channel.smtpNoEncryptionWarning')} />
145+
</div>
146+
{:else}
147+
<div class="flex items-start justify-between gap-4">
148+
<div>
149+
<div class="text-sm font-medium" style="color: var(--ds-text);">
150+
{t('channel.smtpSkipTlsVerify')}
151+
</div>
152+
<DescriptionText>{t('channel.smtpSkipTlsVerifyDescription')}</DescriptionText>
136153
</div>
137-
<DescriptionText>{t('channel.smtpSkipTlsVerifyDescription')}</DescriptionText>
154+
<Toggle bind:checked={formData.skip_tls_verify} dataTestid="smtp-skip-tls-verify" />
138155
</div>
139-
<Toggle bind:checked={formData.skip_tls_verify} dataTestid="smtp-skip-tls-verify" />
140-
</div>
156+
{/if}
141157
</div>
142158
143159
<!-- Authentication -->
144-
<div class="pt-4 border-t" style="border-color: var(--ds-border);">
145-
<h5 class="text-sm font-semibold mb-3" style="color: var(--ds-text);">{t('channel.authentication')}</h5>
146-
<div class="space-y-4">
147-
<div>
148-
<Label color="default" class="mb-2">{t('channel.smtpUsername')}</Label>
149-
<Input type="text" bind:value={formData.username} placeholder={t('channel.smtpUsernamePlaceholder')} />
150-
</div>
151-
<div>
152-
<Label color="default" class="mb-2">{t('channel.smtpPassword')}</Label>
153-
<Input type="password" bind:value={formData.password} placeholder={t('channel.secretPlaceholder')} />
154-
<DescriptionText>
155-
{t('channel.leaveBlankPassword')}
156-
</DescriptionText>
160+
{#if formData.encryption !== 'none'}
161+
<div class="pt-4 border-t" style="border-color: var(--ds-border);">
162+
<h5 class="text-sm font-semibold mb-3" style="color: var(--ds-text);">{t('channel.authentication')}</h5>
163+
<div class="space-y-4">
164+
<div>
165+
<Label color="default" class="mb-2">{t('channel.smtpUsername')}</Label>
166+
<Input type="text" bind:value={formData.username} placeholder={t('channel.smtpUsernamePlaceholder')} dataTestid="smtp-username" />
167+
</div>
168+
<div>
169+
<Label color="default" class="mb-2">{t('channel.smtpPassword')}</Label>
170+
<Input type="password" bind:value={formData.password} placeholder={t('channel.secretPlaceholder')} dataTestid="smtp-password" />
171+
<DescriptionText>
172+
{t('channel.leaveBlankPassword')}
173+
</DescriptionText>
174+
</div>
157175
</div>
158176
</div>
159-
</div>
177+
{/if}
160178
161179
<!-- Sender Settings -->
162180
<div class="pt-4 border-t" style="border-color: var(--ds-border);">

frontend/src/lib/locales/ar/channels.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ export default {
258258
smtpEncryption: 'التشفير',
259259
smtpSkipTlsVerify: 'تخطي التحقق من الشهادة',
260260
smtpSkipTlsVerifyDescription: 'السماح بالشهادات الموقعة ذاتيًا أو غير الموثوقة لخادم SMTP هذا فقط. يؤدي ذلك إلى إضعاف أمان الاتصال.',
261+
smtpNoEncryptionWarning: 'سيتم إرسال محتوى البريد الإلكتروني وبيانات التسليم الوصفية دون تشفير. استخدم هذا الخيار فقط مع مرحل SMTP محلي موثوق.',
261262
noEncryption: 'بدون تشفير (غير مستحسن)',
262263
authentication: 'المصادقة',
263264
senderSettings: 'إعدادات المرسل',

frontend/src/lib/locales/de/channels.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ export default {
245245
smtpEncryption: 'Verschlüsselung',
246246
smtpSkipTlsVerify: 'Zertifikatsprüfung überspringen',
247247
smtpSkipTlsVerifyDescription: 'Selbstsignierte oder nicht vertrauenswürdige Zertifikate nur für diesen SMTP-Server zulassen. Dies schwächt die Verbindungssicherheit.',
248+
smtpNoEncryptionWarning: 'E-Mail-Inhalte und Zustellungsmetadaten werden unverschlüsselt übertragen. Verwenden Sie dies nur mit einem vertrauenswürdigen lokalen SMTP-Relay.',
248249
noEncryption: 'Keine (nicht empfohlen)',
249250
authentication: 'Authentifizierung',
250251
senderSettings: 'Absendereinstellungen',

frontend/src/lib/locales/en/channels.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ export default {
283283
smtpEncryption: 'Encryption',
284284
smtpSkipTlsVerify: 'Skip certificate verification',
285285
smtpSkipTlsVerifyDescription: 'Allow self-signed or untrusted certificates for this SMTP server only. This weakens connection security.',
286+
smtpNoEncryptionWarning: 'Email contents and delivery metadata will be sent without encryption. Use this only with a trusted local SMTP relay.',
286287
noEncryption: 'None (not recommended)',
287288
authentication: 'Authentication',
288289
senderSettings: 'Sender Settings',

frontend/src/lib/locales/es/channels.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ export default {
261261
smtpEncryption: 'Cifrado',
262262
smtpSkipTlsVerify: 'Omitir la verificación del certificado',
263263
smtpSkipTlsVerifyDescription: 'Permite certificados autofirmados o no confiables solo para este servidor SMTP. Esto reduce la seguridad de la conexión.',
264+
smtpNoEncryptionWarning: 'El contenido del correo y los metadatos de entrega se enviarán sin cifrar. Use esta opción solo con un relé SMTP local de confianza.',
264265
noEncryption: 'Ninguno (no recomendado)',
265266
authentication: 'Autenticación',
266267
senderSettings: 'Configuración del remitente',

frontend/src/lib/locales/pt-BR/channels.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ export default {
260260
smtpEncryption: 'Criptografia',
261261
smtpSkipTlsVerify: 'Ignorar verificação do certificado',
262262
smtpSkipTlsVerifyDescription: 'Permite certificados autoassinados ou não confiáveis somente para este servidor SMTP. Isso reduz a segurança da conexão.',
263+
smtpNoEncryptionWarning: 'O conteúdo do e-mail e os metadados de entrega serão enviados sem criptografia. Use esta opção apenas com um relay SMTP local confiável.',
263264
noEncryption: 'Nenhuma (não recomendado)',
264265
authentication: 'Autenticação',
265266
senderSettings: 'Configurações do Remetente',

frontend/src/lib/locales/zh-CN/channels.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export default {
243243
smtpEncryption: '加密',
244244
smtpSkipTlsVerify: '跳过证书验证',
245245
smtpSkipTlsVerifyDescription: '仅允许此 SMTP 服务器使用自签名或不受信任的证书。这会降低连接安全性。',
246+
smtpNoEncryptionWarning: '邮件内容和投递元数据将不加密发送。仅可将此选项用于受信任的本地 SMTP 中继。',
246247
noEncryption: '无(不推荐)',
247248
authentication: '认证',
248249
senderSettings: '发件人设置',

internal/models/portal.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ type ChannelConfig struct {
4343
SMTPPassword string `json:"smtp_password,omitempty"`
4444
SMTPFromEmail string `json:"smtp_from_email,omitempty"`
4545
SMTPFromName string `json:"smtp_from_name,omitempty"`
46-
SMTPEncryption string `json:"smtp_encryption,omitempty"` // tls, ssl, none
46+
SMTPEncryption string `json:"smtp_encryption,omitempty"` // tls/starttls, ssl, none
4747
SMTPSkipTLSVerify bool `json:"smtp_skip_tls_verify,omitempty"`
4848

4949
// Webhook Configuration

internal/services/channel_config_update_service.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,16 +325,16 @@ func (s *ChannelConfigUpdateService) prepareWebhookEnable(config *models.Channel
325325
}
326326

327327
// prepareSMTPEnable validates an outbound SMTP channel before activation:
328-
// host, port, from address (as a bare mailbox), and an allowed TLS mode.
328+
// host, port, from address, transport mode, and authentication policy.
329329
func (s *ChannelConfigUpdateService) prepareSMTPEnable(config *models.ChannelConfig) error {
330330
if strings.TrimSpace(config.SMTPHost) == "" || config.SMTPPort <= 0 || config.SMTPPort > 65535 || strings.TrimSpace(config.SMTPFromEmail) == "" {
331331
return channelConfigInvalid("SMTP host, port, and from address are required before enabling this channel")
332332
}
333333
if !validBareEmail(strings.TrimSpace(config.SMTPFromEmail)) {
334334
return channelConfigInvalid("SMTP from address must be a valid bare email address")
335335
}
336-
if !windshiftsmtp.EncryptionModeAllowed(config.SMTPEncryption) {
337-
return channelConfigInvalid("SMTP encryption must be tls, starttls, or ssl")
336+
if err := windshiftsmtp.ValidateTransport(config); err != nil {
337+
return channelConfigInvalid(err.Error())
338338
}
339339
return nil
340340
}
@@ -714,8 +714,11 @@ func validateEnabledChannel(channel *models.Channel, config *models.ChannelConfi
714714
}
715715
case "smtp":
716716
from := strings.TrimSpace(config.SMTPFromEmail)
717-
if strings.TrimSpace(config.SMTPHost) == "" || config.SMTPPort <= 0 || config.SMTPPort > 65535 || from == "" || !validBareEmail(from) || !windshiftsmtp.EncryptionModeAllowed(config.SMTPEncryption) {
718-
return channelConfigInvalid("Enabled SMTP channels require a valid host, port, from address, and TLS mode")
717+
if strings.TrimSpace(config.SMTPHost) == "" || config.SMTPPort <= 0 || config.SMTPPort > 65535 || from == "" || !validBareEmail(from) {
718+
return channelConfigInvalid("Enabled SMTP channels require a valid host, port, from address, and transport mode")
719+
}
720+
if err := windshiftsmtp.ValidateTransport(config); err != nil {
721+
return channelConfigInvalid(err.Error())
719722
}
720723
}
721724
return nil

internal/smtp/notification_sender.go

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"net"
1313
"net/mail"
1414
"net/smtp"
15-
"os"
1615
"strings"
1716
"time"
1817

@@ -23,37 +22,44 @@ import (
2322
"windshift/internal/utils"
2423
)
2524

26-
// e2eInsecureSMTPEnv, when set to "1", unlocks plaintext SMTP over loopback.
27-
// Used solely by the e2e harness (run-e2e.sh exports it) so Mailpit can
28-
// capture transactional sends without a TLS dance. Production deployments
29-
// must never set this — leaving it unset preserves the dispatch() rejection
30-
// of encryption="none" and SafeNetDialer's loopback block.
31-
const e2eInsecureSMTPEnv = "WINDSHIFT_E2E_INSECURE_SMTP"
32-
33-
func e2eInsecureSMTPEnabled() bool {
34-
return os.Getenv(e2eInsecureSMTPEnv) == "1"
35-
}
36-
3725
// EncryptionModeAllowed reports whether the sender can dispatch with the
38-
// configured mode. Plaintext is deliberately available only to the local E2E
39-
// harness; channel validation and dispatch must use the same policy so a test
40-
// configuration cannot be accepted by one layer and rejected by the other.
26+
// configured mode. Unknown and empty modes are rejected so a typo can never
27+
// silently downgrade a connection to plaintext.
4128
func EncryptionModeAllowed(mode string) bool {
4229
switch strings.ToLower(strings.TrimSpace(mode)) {
43-
case "tls", "starttls", "ssl":
30+
case "tls", "starttls", "ssl", "none":
4431
return true
45-
case "none":
46-
return e2eInsecureSMTPEnabled()
4732
default:
4833
return false
4934
}
5035
}
5136

37+
// ValidateTransport rejects unsupported modes and authentication without TLS.
38+
// Plaintext SMTP is intended for trusted relays that authorize by network;
39+
// credentials must never be exposed on the connection.
40+
func ValidateTransport(config *models.ChannelConfig) error {
41+
if config == nil {
42+
return fmt.Errorf("SMTP config is required")
43+
}
44+
mode := strings.ToLower(strings.TrimSpace(config.SMTPEncryption))
45+
if !EncryptionModeAllowed(mode) {
46+
return fmt.Errorf("SMTP encryption %q not allowed; use \"tls\", \"starttls\", \"ssl\", or \"none\"", config.SMTPEncryption)
47+
}
48+
if mode == "none" && (strings.TrimSpace(config.SMTPUsername) != "" || config.SMTPPassword != "") {
49+
return ErrSMTPAuthenticationRequiresTLS
50+
}
51+
return nil
52+
}
53+
5254
// ErrSMTPNotConfigured is returned when a transactional send is attempted but
5355
// SMTP isn't configured. Re-exported by `internal/services` so existing
5456
// callers (e.g. internal/handlers/auth.go) keep working.
5557
var ErrSMTPNotConfigured = errors.New("SMTP is not configured")
5658

59+
// ErrSMTPAuthenticationRequiresTLS is returned when a plaintext channel
60+
// contains credentials that would otherwise be exposed on the connection.
61+
var ErrSMTPAuthenticationRequiresTLS = errors.New("SMTP authentication requires TLS")
62+
5763
// Encryptor mirrors email.Encryptor — duplicated here to avoid an
5864
// smtp→email→services→smtp import cycle. *sso.SecretEncryption (the same
5965
// concrete type passed to email.NewCredentialManager) satisfies both
@@ -422,13 +428,10 @@ func formatMessageIDHeader(value string) string {
422428
return "<" + value + ">"
423429
}
424430

425-
// dispatch picks the encryption path (TLS/SSL) and sends the assembled MIME
431+
// dispatch picks the configured transport and sends the assembled MIME
426432
// message. Shared by sendEmail and SendThreadedEmail so the encryption switch
427-
// lives in exactly one place. Plaintext SMTP is refused — it ships AUTH PLAIN
428-
// credentials in the clear and bypasses the server-identity check, mirroring
429-
// how email/imap_client.go rejects plaintext IMAP. Misconfigurations (empty
430-
// or typo'd SMTPEncryption) are surfaced as errors instead of silently
431-
// downgrading.
433+
// lives in exactly one place. Plaintext SMTP is allowed only without
434+
// authentication; empty or unknown modes are errors rather than downgrades.
432435
//
433436
// dispatch is a method (rather than a free function) so it can decrypt the
434437
// at-rest SMTPPassword before passing it to AUTH PLAIN — every caller goes
@@ -449,6 +452,9 @@ func (s *NotificationSMTPSender) dispatch(config *models.ChannelConfig, toEmail,
449452
if strings.TrimSpace(config.SMTPHost) == "" || config.SMTPPort <= 0 || config.SMTPPort > 65535 {
450453
return fmt.Errorf("invalid SMTP host or port")
451454
}
455+
if err := ValidateTransport(config); err != nil {
456+
return err
457+
}
452458
password, err := decryptOrLegacy(s.encryption, config.SMTPPassword)
453459
if err != nil {
454460
return err
@@ -461,18 +467,15 @@ func (s *NotificationSMTPSender) dispatch(config *models.ChannelConfig, toEmail,
461467

462468
addr := fmt.Sprintf("%s:%d", config.SMTPHost, config.SMTPPort)
463469

464-
switch strings.ToLower(config.SMTPEncryption) {
470+
switch strings.ToLower(strings.TrimSpace(config.SMTPEncryption)) {
465471
case "tls", "starttls":
466472
return sendWithStartTLS(addr, auth, fromEmail, toEmail, message, config.SMTPSkipTLSVerify)
467473
case "ssl":
468474
return sendWithSSL(addr, auth, fromEmail, toEmail, message, config.SMTPSkipTLSVerify)
469475
case "none":
470-
if EncryptionModeAllowed(config.SMTPEncryption) {
471-
return sendPlaintext(addr, auth, fromEmail, toEmail, message)
472-
}
473-
fallthrough
476+
return sendPlaintext(addr, fromEmail, toEmail, message)
474477
default:
475-
return fmt.Errorf("SMTP encryption %q not allowed; use \"tls\", \"starttls\", or \"ssl\"", config.SMTPEncryption)
478+
return fmt.Errorf("SMTP encryption %q not allowed", config.SMTPEncryption)
476479
}
477480
}
478481

@@ -546,12 +549,11 @@ func smtpTLSConfig(addr string, skipTLSVerify bool) *tls.Config {
546549
return config
547550
}
548551

549-
// sendPlaintext sends email over an unencrypted SMTP connection. Gated by
550-
// WINDSHIFT_E2E_INSECURE_SMTP=1 (set only by the e2e harness so Mailpit on
551-
// loopback can capture transactional sends). Uses a plain net.Dialer rather
552-
// than utils.SafeNetDialer so 127.0.0.1 is reachable.
553-
func sendPlaintext(addr string, auth smtp.Auth, from, to, message string) error {
554-
conn, err := (&net.Dialer{Timeout: smtpDialTimeout}).Dial("tcp", addr)
552+
// sendPlaintext sends unauthenticated email over an unencrypted connection.
553+
// SafeNetDialer keeps the process-wide local-connection policy effective for
554+
// this transport just as it is for TLS SMTP.
555+
func sendPlaintext(addr, from, to, message string) error {
556+
conn, err := utils.SafeNetDialer(smtpDialTimeout).Dial("tcp", addr)
555557
if err != nil {
556558
return err
557559
}
@@ -566,7 +568,7 @@ func sendPlaintext(addr string, auth smtp.Auth, from, to, message string) error
566568
}
567569
defer func() { _ = client.Close() }()
568570

569-
return sendWithClient(client, auth, from, to, message)
571+
return sendWithClient(client, nil, from, to, message)
570572
}
571573

572574
// sendWithClient performs authentication, addressing, and message delivery on an established SMTP client.

0 commit comments

Comments
 (0)