补齐IMAP和POP3配置并统一邮件协议监听端口

This commit is contained in:
smail 2026-09-06 15:53:59 +08:00
parent 9b60eb2177
commit 24829c1199
27 changed files with 868 additions and 80 deletions

View File

@ -0,0 +1,48 @@
package config
// 邮件监听与发信中继相互独立。零值表示沿用兼容默认值,不表示关闭服务。
type SMTPListenerConfig struct {
Port int `json:"port"`
STARTTLSPort int `json:"starttlsPort"`
TLSPort int `json:"tlsPort"`
}
type IMAPListenerConfig struct {
TLSPort int `json:"tlsPort"`
}
type POP3ListenerConfig struct {
Port int `json:"port"`
TLSPort int `json:"tlsPort"`
}
// ResolveMailPorts 是公开配置视图、冲突校验及真实监听的共同端口来源。
func ResolveMailPorts(smtp SMTPSectionConfig, email EmailSectionConfig) MailPorts {
portOr := func(port, fallback int) int {
if validPort(port) {
return port
}
return fallback
}
return MailPorts{
SMTP: portOr(smtp.Listener.Port, portOr(email.Policy.SMTPPort, 25)),
SMTPSTARTTLS: portOr(smtp.Listener.STARTTLSPort, 587),
SMTPTLS: portOr(smtp.Listener.TLSPort, 465),
// 旧版 imapPort 从未用于监听;保留原有的 993,避免升级后擅自切换客户端端口。
IMAP: portOr(email.IMAP.TLSPort, 993),
POP3: portOr(email.POP3.Port, portOr(email.Policy.POP3Port, 110)),
POP3TLS: portOr(email.POP3.TLSPort, 995),
}
}
func validateMailListeners(smtp SMTPSectionConfig, email EmailSectionConfig) error {
for _, port := range []int{
smtp.Listener.Port, smtp.Listener.STARTTLSPort, smtp.Listener.TLSPort,
email.IMAP.TLSPort, email.POP3.Port, email.POP3.TLSPort,
} {
if port != 0 && !validPort(port) {
return ErrPortConflict
}
}
return nil
}

View File

@ -0,0 +1,148 @@
package config
import (
"context"
"encoding/json"
"errors"
"testing"
)
func TestMailListenerConfigurationOverridesLegacyPolicyAndIgnoresRelay(t *testing.T) {
previous := CurrentManager()
defer SetRuntimeManager(previous)
runtime := validRuntimeConfig()
runtime.SMTP.Listener = SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465}
runtime.SMTP.Relay.RelayPort = 1993
runtime.Email.IMAP = IMAPListenerConfig{TLSPort: 1993}
runtime.Email.POP3 = POP3ListenerConfig{Port: 1110, TLSPort: 1995}
SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}})
want := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 1993, POP3: 1110, POP3TLS: 1995}
if got := CurrentMailPorts(); got != want {
t.Fatalf("listeners = %+v, want %+v", got, want)
}
}
func TestMailListenerValidationCoversEveryActualListener(t *testing.T) {
for name, mutate := range map[string]func(*RuntimeConfig){
"smtp_http": func(r *RuntimeConfig) { r.SMTP.Listener.Port = 80 },
"smtp_starttls_https": func(r *RuntimeConfig) { r.SMTP.Listener.STARTTLSPort = 443 },
"smtp_tls_imap": func(r *RuntimeConfig) { r.SMTP.Listener.TLSPort = 993 },
"imap_pop3_tls": func(r *RuntimeConfig) { r.Email.IMAP.TLSPort = 995 },
"pop3_smtp": func(r *RuntimeConfig) { r.Email.POP3.Port = 25 },
"pop3_tls_starttls": func(r *RuntimeConfig) { r.Email.POP3.TLSPort = 587 },
"http_default": func(r *RuntimeConfig) { r.Root.HTTP.Port = 0; r.Email.IMAP.TLSPort = 80 },
"smtp_negative": func(r *RuntimeConfig) { r.SMTP.Listener.Port = -1 },
"starttls_overflow": func(r *RuntimeConfig) { r.SMTP.Listener.STARTTLSPort = 65536 },
"tls_negative": func(r *RuntimeConfig) { r.SMTP.Listener.TLSPort = -1 },
"imap_overflow": func(r *RuntimeConfig) { r.Email.IMAP.TLSPort = 65536 },
"pop3_negative": func(r *RuntimeConfig) { r.Email.POP3.Port = -1 },
"pop3_tls_overflow": func(r *RuntimeConfig) { r.Email.POP3.TLSPort = 65536 },
} {
t.Run(name, func(t *testing.T) {
runtime := validRuntimeConfig()
mutate(&runtime)
if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); !errors.Is(err, ErrPortConflict) {
t.Fatalf("validation = %v, want port conflict", err)
}
})
}
}
func TestMailListenerCandidateApplyAndReloadPreservesAllSections(t *testing.T) {
root := t.TempDir()
initial := validRuntimeConfig()
writeRuntimeFixture(t, root, initial)
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
manager, err := NewConfigManager(root, options)
if err != nil {
t.Fatal(err)
}
for _, section := range []Section{SectionSMTP, SectionEmail} {
current := manager.Snapshot()
var value any
if section == SectionSMTP {
current.SMTP.Listener = SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465}
value = current.SMTP
} else {
current.Email.IMAP.TLSPort = 1993
current.Email.POP3 = POP3ListenerConfig{Port: 1110, TLSPort: 1995}
value = current.Email
}
payload, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
updated, err := manager.Update(context.Background(), UpdateRequest{
Section: section, ExpectedGeneration: current.Generation, Payload: payload, Actor: authorizedConfigActor(),
})
if err != nil {
t.Fatal(err)
}
if manager.Snapshot().Generation != current.Generation {
t.Fatal("saving candidate published it prematurely")
}
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, section, authorizedConfigActor()); err != nil {
t.Fatal(err)
}
}
reloaded, err := NewConfigLoader(root, options).Load()
if err != nil {
t.Fatal(err)
}
want := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 1993, POP3: 1110, POP3TLS: 1995}
if got := ResolveMailPorts(reloaded.SMTP, reloaded.Email); got != want {
t.Fatalf("reloaded listeners = %+v, want %+v", got, want)
}
if reloaded.Email.Policy != initial.Email.Policy || reloaded.SMTP.Relay != initial.SMTP.Relay {
t.Fatal("listener change overwrote policy or relay")
}
}
func TestMailListenerConflictsUseEffectiveWebPorts(t *testing.T) {
previous := RuntimeSetupPort()
t.Cleanup(func() { SetRuntimeSetupPort(previous) })
for _, tc := range []struct {
name string
http HTTPConfig
envHTTP string
envHTTPS string
setupPort int
mailPort int
conflict bool
}{
{"environment_http", HTTPConfig{}, "9080", "", 8080, 9080, true},
{"setup_http", HTTPConfig{}, "", "", 8080, 8080, true},
{"configured_http", HTTPConfig{Port: 7080}, "9080", "", 8080, 7080, true},
{"unused_environment_http", HTTPConfig{Port: 7080}, "9080", "", 8080, 9080, false},
{"environment_https", HTTPConfig{HTTPSEnabled: true}, "", "9443", 8080, 9443, true},
{"default_https", HTTPConfig{HTTPSEnabled: true}, "", "", 8080, 443, true},
{"proxy_no_internal_https", HTTPConfig{TLSMode: HTTPSTLSModeProxy}, "", "9443", 8080, 9443, false},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("SMAIL_HTTP_PORT", tc.envHTTP)
t.Setenv("SMAIL_HTTPS_PORT", tc.envHTTPS)
SetRuntimeSetupPort(tc.setupPort)
ports := ResolveMailPorts(SMTPSectionConfig{}, EmailSectionConfig{})
ports.IMAP = tc.mailPort
err := validateRuntimePorts(tc.http, ports)
if errors.Is(err, ErrPortConflict) != tc.conflict {
t.Fatalf("validation = %v, want conflict %v", err, tc.conflict)
}
})
}
}
func TestOldPartitionPayloadsKeepExistingListenerDefaults(t *testing.T) {
var smtp SMTPSectionConfig
var email EmailSectionConfig
if err := ParseStrict([]byte(`{"meta":{"schema":1,"generation":1},"relay":{"relayPort":2465}}`), &smtp); err != nil {
t.Fatal(err)
}
if err := ParseStrict([]byte(`{"meta":{"schema":1,"generation":1},"policy":{"smtpPort":2525,"imapPort":1143,"pop3Port":1110}}`), &email); err != nil {
t.Fatal(err)
}
want := MailPorts{SMTP: 2525, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 1110, POP3TLS: 995}
if got := ResolveMailPorts(smtp, email); got != want {
t.Fatalf("legacy listeners changed: %+v", got)
}
}

View File

@ -162,9 +162,12 @@ func cloneConfig(source *Config) *Config {
}
type MailPorts struct {
SMTP int
IMAP int
POP3 int
SMTP int
SMTPSTARTTLS int
SMTPTLS int
IMAP int
POP3 int
POP3TLS int
}
type MailDomains struct {
@ -273,18 +276,13 @@ func CurrentSMTPRuntimeSettings() (SMTPRuntimeSettings, error) {
}
func CurrentMailPorts() MailPorts {
ports := MailPorts{SMTP: 25, IMAP: 143, POP3: 110}
policy := CurrentEmailPolicy()
if policy.SMTPPort > 0 && policy.SMTPPort <= 65535 {
ports.SMTP = policy.SMTPPort
if manager := CurrentManager(); manager != nil {
if runtime := manager.Snapshot(); runtime.Generation != 0 {
return ResolveMailPorts(runtime.SMTP, runtime.Email)
}
}
if policy.IMAPPort > 0 && policy.IMAPPort <= 65535 {
ports.IMAP = policy.IMAPPort
}
if policy.POP3Port > 0 && policy.POP3Port <= 65535 {
ports.POP3 = policy.POP3Port
}
return ports
// 旧版 SMTPPort 属于发信中继,不能用作本机监听端口。
return ResolveMailPorts(SMTPSectionConfig{}, EmailSectionConfig{})
}
func cloneHTTPConfig(source HTTPConfig) HTTPConfig {
clone := source

View File

@ -70,7 +70,7 @@ func TestCurrentMailPortsUsesSafeDefaultsWithoutRuntimeManager(t *testing.T) {
defer SetRuntimeManager(previousManager)
SetRuntimeManager(nil)
if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, IMAP: 143, POP3: 110}) {
if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 110, POP3TLS: 995}) {
t.Fatalf("mail ports = %+v, want safe defaults", got)
}
}
@ -131,7 +131,7 @@ func TestCurrentMailPortsReadsEmailPolicySnapshot(t *testing.T) {
runtime.Email.Policy.POP3Port = 1110
SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}})
if got := CurrentMailPorts(); got != (MailPorts{SMTP: 2525, IMAP: 1143, POP3: 1110}) {
if got := CurrentMailPorts(); got != (MailPorts{SMTP: 2525, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 1110, POP3TLS: 995}) {
t.Fatalf("mail ports = %+v, want policy ports", got)
}
}
@ -146,7 +146,7 @@ func TestCurrentMailPortsFallsBackForInvalidPolicyValues(t *testing.T) {
runtime.Email.Policy.POP3Port = -1
SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}})
if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, IMAP: 143, POP3: 110}) {
if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 110, POP3TLS: 995}) {
t.Fatalf("mail ports = %+v, want defaults for invalid policy values", got)
}
}
@ -160,7 +160,7 @@ func TestCurrentMailPortsDoesNotReadLegacyRelayPort(t *testing.T) {
runtime.Email.Policy = EmailConfig{}
SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}})
if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, IMAP: 143, POP3: 110}) {
if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 110, POP3TLS: 995}) {
t.Fatalf("mail ports = %+v, legacy relay port leaked into listener ports", got)
}
}
@ -170,7 +170,7 @@ func TestCurrentMailPortsUsesDefaultsForUnavailableSnapshot(t *testing.T) {
defer SetRuntimeManager(previousManager)
SetRuntimeManager(&ConfigManager{})
if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, IMAP: 143, POP3: 110}) {
if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 110, POP3TLS: 995}) {
t.Fatalf("mail ports = %+v, want defaults for unavailable snapshot", got)
}
}

View File

@ -55,7 +55,10 @@ func ValidateRuntimeConfig(runtime RuntimeConfig, options RuntimeValidationOptio
if err := validateDomains(runtime.Root.Domains); err != nil {
return err
}
if err := validateRuntimePorts(runtime.Root.HTTP, runtime.Email.Policy); err != nil {
if err := validateMailListeners(runtime.SMTP, runtime.Email); err != nil {
return err
}
if err := validateRuntimePorts(runtime.Root.HTTP, ResolveMailPorts(runtime.SMTP, runtime.Email)); err != nil {
return err
}
if err := validatePluginSection(runtime.Plugins); err != nil {
@ -65,9 +68,6 @@ func ValidateRuntimeConfig(runtime RuntimeConfig, options RuntimeValidationOptio
if !options.HasSuperAdmin || runtime.Root.Domains.Primary == "" || len(runtime.Root.Domains.Accepted) == 0 || runtime.Root.Domains.Web == "" || !runtime.Root.Database.IsStructured() {
return ErrInvalidRuntimeConfig
}
if runtime.Email.Policy.SMTPPort == 0 || runtime.Email.Policy.IMAPPort == 0 || runtime.Email.Policy.POP3Port == 0 {
return ErrInvalidRuntimeConfig
}
if runtime.Root.HTTP.HTTPSEnabled && runtime.Root.HTTP.HTTPSPort == 0 {
return ErrInvalidRuntimeConfig
}
@ -126,8 +126,10 @@ func normalizeConfigAccessHost(value string) (string, error) {
return normalizeConfigDomain(value)
}
func validateRuntimePorts(http HTTPConfig, email EmailConfig) error {
ports := []int{http.Port, email.SMTPPort, email.IMAPPort, email.POP3Port}
func validateRuntimePorts(http HTTPConfig, mail MailPorts) error {
// 与实际 Web 监听使用相同的环境变量、启动端口及反代默认值。
http = ResolveHTTPPortDefaults(http, RuntimeSetupPort())
ports := []int{http.Port, mail.SMTP, mail.SMTPSTARTTLS, mail.SMTPTLS, mail.IMAP, mail.POP3, mail.POP3TLS}
if http.HTTPSEnabled || http.HTTPSPort != 0 {
ports = append(ports, http.HTTPSPort)
}

View File

@ -61,7 +61,7 @@ func TestValidateRuntimeConfigRejectsCrossSectionViolations(t *testing.T) {
mutate func(*RuntimeConfig, *RuntimeValidationOptions)
want error
}{
{name: "port_conflict", mutate: func(runtime *RuntimeConfig, _ *RuntimeValidationOptions) { runtime.Email.Policy.IMAPPort = 443 }, want: ErrPortConflict},
{name: "port_conflict", mutate: func(runtime *RuntimeConfig, _ *RuntimeValidationOptions) { runtime.Email.IMAP.TLSPort = 443 }, want: ErrPortConflict},
{name: "primary_domain_missing", mutate: func(runtime *RuntimeConfig, _ *RuntimeValidationOptions) {
runtime.Root.Domains.Accepted = []string{"example.com"}
}, want: ErrInvalidDomainConfig},

View File

@ -54,13 +54,16 @@ const (
)
type SMTPSectionConfig struct {
Meta SectionMeta `json:"meta"`
Relay SMTPConfig `json:"relay"`
Meta SectionMeta `json:"meta"`
Relay SMTPConfig `json:"relay"`
Listener SMTPListenerConfig `json:"listener,omitempty"`
}
type EmailSectionConfig struct {
Meta SectionMeta `json:"meta"`
Policy EmailConfig `json:"policy"`
Meta SectionMeta `json:"meta"`
Policy EmailConfig `json:"policy"`
IMAP IMAPListenerConfig `json:"imap,omitempty"`
POP3 POP3ListenerConfig `json:"pop3,omitempty"`
}
type SSLSectionConfig struct {

View File

@ -15,7 +15,7 @@ func TestSnapshotStoreRejectsInvalidCandidate(t *testing.T) {
t.Fatal(err)
}
invalid := validRuntimeConfig()
invalid.Email.Policy.IMAPPort = invalid.Root.HTTP.HTTPSPort
invalid.Email.IMAP.TLSPort = invalid.Root.HTTP.HTTPSPort
if err := store.Publish(invalid); err == nil {
t.Fatal("invalid runtime configuration was published")
}

View File

@ -180,7 +180,7 @@ func TestSnapshotStoreRejectsInvalidCandidateWithoutChangingSnapshot(t *testing.
}
invalid := cloneRuntimeConfig(initial)
invalid.Email.Policy.IMAPPort = invalid.Root.HTTP.HTTPSPort
invalid.Email.IMAP.TLSPort = invalid.Root.HTTP.HTTPSPort
if err := store.Publish(invalid); !errors.Is(err, ErrPortConflict) {
t.Fatalf("publish error = %v, want %v", err, ErrPortConflict)
}
@ -191,8 +191,8 @@ func TestSnapshotStoreRejectsInvalidCandidateWithoutChangingSnapshot(t *testing.
if err != nil {
t.Fatal(err)
}
if after.Email.Policy.IMAPPort != before.Email.Policy.IMAPPort {
t.Fatalf("snapshot changed after rejection: before=%d after=%d", before.Email.Policy.IMAPPort, after.Email.Policy.IMAPPort)
if after.Email.IMAP.TLSPort != before.Email.IMAP.TLSPort {
t.Fatalf("snapshot changed after rejection: before=%d after=%d", before.Email.IMAP.TLSPort, after.Email.IMAP.TLSPort)
}
}
@ -216,7 +216,7 @@ func TestConfigManagerRejectsInvalidUpdateWithoutDiskOrStatusMutation(t *testing
beforeStatus := manager.Status()
current := manager.Snapshot()
invalidEmail := current.Email
invalidEmail.Policy.IMAPPort = current.Root.HTTP.HTTPSPort
invalidEmail.IMAP.TLSPort = current.Root.HTTP.HTTPSPort
payload, err := json.Marshal(invalidEmail)
if err != nil {
t.Fatal(err)

View File

@ -444,8 +444,10 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) (
"security": runtime.Root.Security,
}, nil
case config.SectionSMTP:
ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
return map[string]any{
"meta": runtime.SMTP.Meta,
"meta": runtime.SMTP.Meta,
"listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS},
"relay": map[string]any{
"relayHost": runtime.SMTP.Relay.RelayHost,
"relayPort": runtime.SMTP.Relay.RelayPort,
@ -460,7 +462,11 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) (
"relayPasswordStatus": publicSecret(runtime.SMTP.Relay.Password),
}, nil
case config.SectionEmail:
return runtime.Email, nil
ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
value := runtime.Email
value.IMAP = config.IMAPListenerConfig{TLSPort: ports.IMAP}
value.POP3 = config.POP3ListenerConfig{Port: ports.POP3, TLSPort: ports.POP3TLS}
return value, nil
case config.SectionSSL:
return runtime.SSL, nil
case config.SectionPlugins:

View File

@ -0,0 +1,49 @@
package controllers
import (
"encoding/json"
"testing"
"git.pcux.cn/xuans/Smail/config"
)
func TestPublicMailPortsMatchListenersWithoutChangingSnapshot(t *testing.T) {
for _, custom := range []bool{false, true} {
runtime := config.RuntimeConfig{
SMTP: config.SMTPSectionConfig{Relay: config.SMTPConfig{RelayPort: 9999}},
Email: config.EmailSectionConfig{Policy: config.EmailConfig{SMTPPort: 2525, IMAPPort: 1143, POP3Port: 1110}},
}
if custom {
runtime.SMTP.Listener = config.SMTPListenerConfig{Port: 3025, STARTTLSPort: 3587, TLSPort: 3465}
runtime.Email.IMAP.TLSPort = 3993
runtime.Email.POP3 = config.POP3ListenerConfig{Port: 3110, TLSPort: 3995}
}
before := runtime.Email
want := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
smtpPublic, err := publicConfigSection(runtime, config.SectionSMTP)
if err != nil {
t.Fatal(err)
}
data, err := json.Marshal(smtpPublic)
if err != nil {
t.Fatal(err)
}
var smtp struct {
Listener config.SMTPListenerConfig `json:"listener"`
}
if err := json.Unmarshal(data, &smtp); err != nil {
t.Fatal(err)
}
emailPublic, err := publicConfigSection(runtime, config.SectionEmail)
if err != nil {
t.Fatal(err)
}
email := emailPublic.(config.EmailSectionConfig)
if smtp.Listener.Port != want.SMTP || smtp.Listener.STARTTLSPort != want.SMTPSTARTTLS || smtp.Listener.TLSPort != want.SMTPTLS || email.IMAP.TLSPort != want.IMAP || email.POP3.Port != want.POP3 || email.POP3.TLSPort != want.POP3TLS {
t.Fatal("public ports differ from listener configuration")
}
if before != runtime.Email {
t.Fatal("public view changed runtime configuration")
}
}
}

View File

@ -20,14 +20,14 @@ func Stop() {
}
}
// StarTLS 启动TLS端口监听,不加密的代码就懒得写了
// StarTLS 启动隐式 TLS 监听,不提供明文认证入口。
func StarTLS() error {
server, err := newIMAPServer()
if err != nil {
return err
}
instanceTLS = server
const imapTLSPort = 993
imapTLSPort := config.CurrentMailPorts().IMAP
log.Infof("IMAP With TLS Server Start On Port :%d", imapTLSPort)
if err := instanceTLS.ListenAndServeTLS(fmt.Sprintf(":%d", imapTLSPort)); err != nil {
return fmt.Errorf("serve IMAP TLS: %w", err)

View File

@ -30,10 +30,11 @@ func StartWithTls() error {
tlsConfig.Certificates = []tls.Certificate{crt}
tlsConfig.Time = time.Now
tlsConfig.Rand = rand.Reader
instanceTls = gopop.NewPop3Server(995, "pop."+domains.Primary, true, tlsConfig, action{})
port := config.CurrentMailPorts().POP3TLS
instanceTls = gopop.NewPop3Server(port, "pop."+domains.Primary, true, tlsConfig, action{})
instanceTls.ConnectAliveTime = 5 * time.Minute
log.Infof("POP3 With TLS Server Start On Port :995")
log.Infof("POP3 With TLS Server Start On Port :%d", port)
err = instanceTls.Start()
if err != nil {

View File

@ -34,7 +34,7 @@ func StartWithTLSNew() error {
instanceTlsNew = smtp.NewServer(be)
instanceTlsNew.Addr = ":587"
instanceTlsNew.Addr = fmt.Sprintf(":%d", config.CurrentMailPorts().SMTPSTARTTLS)
instanceTlsNew.Domain = domains.Primary
instanceTlsNew.ReadTimeout = 10 * time.Second
instanceTlsNew.WriteTimeout = 10 * time.Second
@ -51,7 +51,7 @@ func StartWithTLSNew() error {
instanceTlsNew.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}, MinVersion: tls.VersionTLS12}
log.Println("Starting Smtp With STARTTLS Server Port:", instanceTlsNew.Addr)
// 587端口使用STARTTLS(先明文连接,再升级TLS),而非隐式TLS
// 提交端口使用 STARTTLS(先连接,再升级 TLS),而非隐式 TLS。
if err := instanceTlsNew.ListenAndServe(); err != nil && !errors.Is(err, smtp.ErrServerClosed) {
return fmt.Errorf("serve SMTP STARTTLS: %w", err)
}
@ -71,7 +71,7 @@ func StartWithTLS() error {
instanceTls = smtp.NewServer(be)
instanceTls.Addr = ":465"
instanceTls.Addr = fmt.Sprintf(":%d", config.CurrentMailPorts().SMTPTLS)
instanceTls.Domain = domains.Primary
instanceTls.ReadTimeout = 10 * time.Second
instanceTls.WriteTimeout = 10 * time.Second

View File

@ -0,0 +1,34 @@
# 邮件协议监听配置说明
## 配置入口
进入“设置 → 配置管理”,分别打开 SMTP 配置、IMAP 配置、POP3 配置。
| 配置页面 | 监听用途 | 默认端口 |
| --- | --- | --- |
| SMTP 配置 | 接收邮件,支持 STARTTLS | 25 |
| SMTP 配置 | 客户端提交,使用 STARTTLS | 587 |
| SMTP 配置 | 隐式 TLS | 465 |
| IMAP 配置 | 隐式 TLS | 993 |
| POP3 配置 | 使用 STLS 升级加密 | 110 |
| POP3 配置 | 隐式 TLS | 995 |
SMTP 页面中的发信中继是外部服务器连接配置,与本机监听端口无关。IMAP 当前仅提供隐式 TLS,不提供明文或 STARTTLS 监听。
“邮箱策略”自动读取这三个页面的已应用端口,不能再单独修改端口。该汇总不是在线检测,也不展示尚未应用的候选端口。
## 保存与生效
1. 修改对应协议端口,完成近期登录认证并验证配置。
2. 保存候选后核对内容,再应用配置。
3. 根据待重启提示重启后端,使新监听端口生效,并同步调整客户端、反代或防火墙。
IMAP、POP3、邮箱策略是独立页面,但共用邮箱配置分区。候选会提示涉及哪些页面,需要逐页核对;应用时该分区一起生效。配置端口不能与其他邮件监听或 Web 监听冲突。
## 存储与旧配置兼容
- SMTP 监听保存在 SMTP 分区的 `listener`,不覆盖 `relay`。
- IMAP、POP3 分别保存在邮箱分区的 `imap`、`pop3`,保留 `policy`。
- 未配置新 SMTP 收信端口、POP3 STLS 端口时,兼容旧策略端口;未配置其他端口时采用表中默认值。零值表示兼容默认,不表示禁用服务。
- 旧 `policy.imapPort` 从未控制实际监听,保留该字段但不把原有 TLS 端口从 993 自动迁移到 143。
- 保持原有配置分区文件结构,无需新增独立 IMAP、POP3 配置文件。

View File

@ -88,6 +88,7 @@
class="config-alert"
/>
<MailPortSummary v-if="mailPortSummary && !sectionLoading && !sectionError" :services="mailPortSummary" />
<ConfigForm
v-if="!sectionLoading && !sectionError"
:section="selectedSection"
@ -124,6 +125,13 @@
:closable="false"
show-icon
/>
<el-alert
v-if="pendingScopeHint"
:title="pendingScopeHint"
type="warning"
:closable="false"
show-icon
/>
<el-checkbox v-model="confirmed" class="confirmation-check" :disabled="sectionLoading || !!working || !!sectionError">
我已确认这是受控变更,并允许使用最近认证完成高风险配置操作
</el-checkbox>
@ -164,8 +172,10 @@
<script setup>
import { inject } from 'vue'
import ConfigForm from '../ConfigForm/ConfigForm.vue'
import MailPortSummary from '../MailPortSummary/MailPortSummary.vue'
const {
mailPortSummary,
availabilityError,
loading,
working,
@ -186,6 +196,7 @@ const {
confirmed,
savedGeneration,
pendingCandidate,
pendingScopeHint,
managePending,
validationResult,
canWrite,

View File

@ -33,6 +33,18 @@ export function getRecommendedPort(config, path) {
return 443
case 'relay.relayPort':
return smtpPorts.get(config.relay?.security)
case 'listener.port':
return 25
case 'listener.starttlsPort':
return 587
case 'listener.tlsPort':
return 465
case 'imap.tlsPort':
return 993
case 'pop3.port':
return 110
case 'pop3.tlsPort':
return 995
default:
return undefined
}
@ -105,6 +117,11 @@ const baseGroups = [
]
const smtpGroups = [
group('listener', 'SMTP 服务监听', '', [
port('listener.port', 'SMTP 收信端口', { min: 1, hint: '默认 25,用于接收其他邮件服务器投递,支持 STARTTLS;不是发信中继端口。' }),
port('listener.starttlsPort', 'SMTP 提交端口(STARTTLS)', { min: 1, hint: '默认 587,客户端升级 TLS 后进行认证。' }),
port('listener.tlsPort', 'SMTP 隐式 TLS 端口', { min: 1, hint: '默认 465,客户端建立连接时直接使用 TLS。' })
]),
group('relay', 'SMTP 中继连接', '用于发送验证及通知邮件。未使用中继时可保留空值。', [
field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }),
field('relay.security', '连接加密', 'select', {
@ -117,7 +134,7 @@ const smtpGroups = [
]),
hint: '切换加密方式会自动匹配推荐端口,之后仍可手动填写服务商指定的端口。'
}),
port('relay.relayPort', 'SMTP 端口', { hint: '先选择加密方式,系统自动填写端口;自定义端口不会改变加密方式。' }),
port('relay.relayPort', 'SMTP 发信中继端口', { hint: '外部 SMTP 服务器端口,与本机监听无关;选择加密方式后自动填写。' }),
field('relay.username', '认证用户名', 'text', { placeholder: '需要认证时填写' }),
field('relay.from', '发件邮箱', 'text', { placeholder: 'noreply@example.com' }),
field('relay.fromDomain', '发件域名', 'text', { placeholder: 'example.com' })
@ -128,12 +145,20 @@ const smtpGroups = [
])
]
const imapGroups = [
group('imap', 'IMAP 服务监听', '', [
port('imap.tlsPort', 'IMAP 隐式 TLS 端口', { min: 1, required: true, hint: '默认 993。当前仅支持隐式 TLS,不提供明文或 STARTTLS 监听。' })
])
]
const pop3Groups = [
group('pop3', 'POP3 服务监听', '', [
port('pop3.port', 'POP3 端口(STLS)', { min: 1, required: true, hint: '默认 110,客户端使用 STLS 升级加密后认证。' }),
port('pop3.tlsPort', 'POP3 隐式 TLS 端口', { min: 1, required: true, hint: '默认 995,客户端建立连接时直接使用 TLS。' })
])
]
const emailGroups = [
group('ports', '邮件服务端口', '各服务端口不能重复,最终可用性由后端统一校验。', [
port('policy.smtpPort', 'SMTP 收信端口', { min: 1, required: true }),
port('policy.imapPort', 'IMAP 端口', { min: 1, required: true }),
port('policy.pop3Port', 'POP3 端口', { min: 1, required: true })
]),
group('policy', '邮件与账号策略', '设置邮件大小、垃圾邮件过滤和账号恢复方式。', [
field('policy.maxMessageBytes', '最大邮件大小(字节)', 'number', { min: 0, max: Number.MAX_SAFE_INTEGER, integer: true, hint: '0 使用后端默认限制;1 MiB = 1048576 字节。' }),
field('policy.spamFilterLevel', '垃圾邮件过滤级别', 'select', { fallback: 0, options: options([[0, '不过滤'], [1, 'SPF 与 DKIM 均失败时过滤'], [2, 'SPF 失败时过滤'], [3, 'DKIM 失败时过滤']]) }),
@ -175,7 +200,7 @@ const pluginDefinitions = {
export function getFieldGroups(section, config) {
if (section !== 'plugins') {
return ({ base: baseGroups, smtp: smtpGroups, email: emailGroups, ssl: sslGroups }[section] || [])
return ({ base: baseGroups, smtp: smtpGroups, imap: imapGroups, pop3: pop3Groups, email: emailGroups, ssl: sslGroups }[section] || [])
.map(item => ({
...item,
fields: item.fields
@ -288,11 +313,13 @@ export function validateFormConfig(section, config) {
errors['relay.relayHost'] = '不加密连接仅限本机地址;远程服务器请选择 STARTTLS 或隐式 TLS'
}
}
if (section === 'email') {
const ports = ['smtpPort', 'imapPort', 'pop3Port']
for (const name of ports) {
const value = config.policy?.[name]
if (value > 0 && ports.some(other => other !== name && config.policy?.[other] === value)) errors[`policy.${name}`] = '邮件服务端口不能重复'
if (['smtp', 'imap', 'pop3'].includes(section)) {
const paths = section === 'smtp'
? ['listener.port', 'listener.starttlsPort', 'listener.tlsPort']
: ['imap.tlsPort', 'pop3.port', 'pop3.tlsPort']
for (const path of paths) {
const value = getValue(config, path.split('.'))
if (value > 0 && paths.some(other => other !== path && getValue(config, other.split('.')) === value)) errors[path] = '邮件服务监听端口不能重复'
}
}
if (section === 'ssl') {

View File

@ -2,6 +2,8 @@ import { computed, nextTick, ref } from 'vue'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import BaseConfig from './BaseConfig/BaseConfig.vue'
import SmtpConfig from './SmtpConfig/SmtpConfig.vue'
import ImapConfig from './ImapConfig/ImapConfig.vue'
import Pop3Config from './Pop3Config/Pop3Config.vue'
import EmailConfig from './EmailConfig/EmailConfig.vue'
import SslConfig from './SslConfig/SslConfig.vue'
import PluginsConfig from './PluginsConfig/PluginsConfig.vue'
@ -16,6 +18,8 @@ export function useNavigation() {
const pages = [
{ value: 'base', label: '基础配置', component: BaseConfig },
{ value: 'smtp', label: 'SMTP 配置', component: SmtpConfig },
{ value: 'imap', label: 'IMAP 配置', component: ImapConfig },
{ value: 'pop3', label: 'POP3 配置', component: Pop3Config },
{ value: 'email', label: '邮箱策略', component: EmailConfig },
{ value: 'ssl', label: 'SSL 配置', component: SslConfig },
{ value: 'plugins', label: '插件配置', component: PluginsConfig }

View File

@ -6,8 +6,10 @@ import { readFormConfig, updateFormConfig, validateFormConfig } from './ConfigFo
const sections = [
{ value: 'base', label: '基础配置', description: '管理站点与数据库连接,敏感凭据单独填写。' },
{ value: 'smtp', label: 'SMTP 配置', description: '管理邮件发送与中继连接,密码留空时保持原值。' },
{ value: 'email', label: '邮箱策略', description: '管理邮件协议端口与账号恢复策略。' },
{ value: 'smtp', label: 'SMTP 配置', description: '管理本机监听与发信中继;监听端口变更需应用配置并重启后端。' },
{ value: 'imap', label: 'IMAP 配置', description: '管理 IMAP 隐式 TLS 监听;端口变更需应用配置并重启后端。' },
{ value: 'pop3', label: 'POP3 配置', description: '管理 POP3 的 STLS 与隐式 TLS 监听;端口变更需应用配置并重启后端。' },
{ value: 'email', label: '邮箱策略', description: '自动汇总三个协议的配置端口,管理邮件与账号恢复策略。' },
{ value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' },
{ value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' }
]
@ -49,6 +51,8 @@ export function usePage(pageKey = 'base') {
degradedReasons: []
})
const selectedSection = ref(isConfigPage ? pageKey : 'base')
// 页面与存储分区分离,IMAP、POP3 复用邮箱分区的原子保存与候选保护。
const apiSection = () => ['imap', 'pop3'].includes(selectedSection.value) ? 'email' : selectedSection.value
const currentSection = computed(() => sections.find(item => item.value === selectedSection.value) || sections[0])
const sectionLoading = ref(false)
let sectionRequestId = 0
@ -57,11 +61,47 @@ export function usePage(pageKey = 'base') {
const formConfig = computed(() => readFormConfig(editorText.value))
const formErrors = ref({})
const loadedText = ref('')
const smtpListener = ref(null)
const mailPortSummary = computed(() => {
if (selectedSection.value !== 'email' || !loadedText.value || !smtpListener.value) return null
const email = readFormConfig(loadedText.value)
return [
{ name: 'SMTP', ports: [
{ label: '收信 / STARTTLS', value: smtpListener.value.port },
{ label: '提交 / STARTTLS', value: smtpListener.value.starttlsPort },
{ label: '隐式 TLS', value: smtpListener.value.tlsPort }
] },
{ name: 'IMAP', ports: [{ label: '隐式 TLS', value: email.imap?.tlsPort }] },
{ name: 'POP3', ports: [
{ label: 'STLS', value: email.pop3?.port },
{ label: '隐式 TLS', value: email.pop3?.tlsPort }
] }
]
})
const sectionError = ref('')
const secretValue = ref('')
const confirmed = ref(false)
const savedGeneration = ref(0)
const pendingCandidate = ref(null)
const pendingScopeHint = computed(() => {
const pending = pendingCandidate.value
if (pending?.section !== 'email' || apiSection() !== 'email') return ''
const active = readFormConfig(loadedText.value)
const candidate = pending.payload || {}
const changed = [
['imap', 'IMAP 配置'],
['pop3', 'POP3 配置'],
['policy', '邮箱策略']
].filter(([key]) => {
const before = active[key] || {}
const after = candidate[key] || {}
return Object.keys({ ...before, ...after }).some(field => JSON.stringify(before[field]) !== JSON.stringify(after[field]))
}).map(([, label]) => label)
const scope = pending.payload && loadedText.value
? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的协议端口和邮箱策略未变化。')
: '候选内容尚未完整读取,请刷新后核对。'
return `${scope}IMAP、POP3 和邮箱策略共用一个配置分区,请逐页核对;应用时会一起生效,监听端口仍需重启后端。`
})
let savedInput = null
const validationResult = ref(null)
const backups = ref([])
@ -197,7 +237,7 @@ export function usePage(pageKey = 'base') {
savedInput = null
const pending = pendingCandidate.value
const section = pending?.section === 'root' ? 'base' : pending?.section
if (pending && section === selectedSection.value) {
if (pending && section === apiSection()) {
editorText.value = JSON.stringify(pending.payload, null, 2)
secretValue.value = ''
savedInput = { text: editorText.value, secret: '' }
@ -234,12 +274,13 @@ export function usePage(pageKey = 'base') {
const loadSection = async () => {
const requestId = ++sectionRequestId
const section = selectedSection.value
const section = apiSection()
sectionLoading.value = true
sectionError.value = ''
formErrors.value = {}
editorText.value = ''
loadedText.value = ''
smtpListener.value = null
secretValue.value = ''
confirmed.value = false
validationResult.value = null
@ -250,6 +291,16 @@ export function usePage(pageKey = 'base') {
if (requestId !== sectionRequestId) return
notifyResponseError(response, '配置分区读取失败')
const publicConfig = response.data || {}
if (selectedSection.value === 'email') {
const smtpResponse = await http.get('/api/v1/config/smtp')
if (requestId !== sectionRequestId) return
notifyResponseError(smtpResponse, 'SMTP 监听配置读取失败')
// 不拼接不同代次的配置,也不在读取失败时显示旧端口。
if (smtpResponse.data?.meta?.generation !== publicConfig.meta?.generation) {
throw new Error('配置代次已变化,请刷新后查看最新端口')
}
smtpListener.value = smtpResponse.data?.listener || null
}
editorText.value = JSON.stringify(publicConfig, null, 2)
loadedText.value = editorText.value
if (section === 'base') {
@ -502,7 +553,7 @@ export function usePage(pageKey = 'base') {
working.value = 'validate'
try {
const request = buildRequest()
const response = await http.post(`/api/v1/config/${selectedSection.value}/validate`, request)
const response = await http.post(`/api/v1/config/${apiSection()}/validate`, request)
notifyResponseError(response, '配置验证失败')
validationResult.value = response.data || null
ElMessage[validationResult.value?.valid ? 'success' : 'warning'](validationResult.value?.valid ? '配置验证通过' : '配置验证未通过')
@ -518,10 +569,10 @@ export function usePage(pageKey = 'base') {
working.value = 'save'
try {
const request = buildRequest()
const response = await http.put(`/api/v1/config/${selectedSection.value}`, request)
const response = await http.put(`/api/v1/config/${apiSection()}`, request)
notifyResponseError(response, '配置保存失败')
savedGeneration.value = response.data?.generation || 0
pendingCandidate.value = { ...response.data, section: selectedSection.value, sameSession: true }
pendingCandidate.value = { ...response.data, section: apiSection(), sameSession: true }
savedInput = { text: editorText.value, secret: secretValue.value }
validationResult.value = { valid: true, generation: savedGeneration.value, issues: [] }
await loadStatus()
@ -543,7 +594,7 @@ export function usePage(pageKey = 'base') {
}
working.value = 'apply'
try {
const response = await http.post(`/api/v1/config/${selectedSection.value}/apply`, {
const response = await http.post(`/api/v1/config/${apiSection()}/apply`, {
generation: savedGeneration.value,
candidateId: pendingCandidate.value?.candidateId
})
@ -665,6 +716,8 @@ export function usePage(pageKey = 'base') {
})
return {
mailPortSummary,
pendingScopeHint,
loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections,
selectedSection, currentSection, sectionLoading, editorText, formConfig, formErrors, updateField, updateSecret,
sectionError, sensitiveHint, secretField, secretValue,

View File

@ -0,0 +1,13 @@
<template>
<ConfigEditor />
</template>
<script setup>
import { provide } from 'vue'
import { usePage } from '../ConfigSettings.js'
import ConfigEditor from '../ConfigEditor/ConfigEditor.vue'
const page = usePage('imap')
provide('smail-config-page', page)
defineExpose({ beforeLeave: page.beforeLeave })
</script>

View File

@ -0,0 +1,52 @@
.mail-port-summary {
margin-bottom: 24px;
}
.summary-note {
margin: 0 0 14px;
color: var(--pm-text-secondary);
font-size: 12px;
line-height: 1.7;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.protocol-card {
padding: 16px;
border: 1px solid var(--pm-border-color);
border-radius: 10px;
min-width: 0;
}
.protocol-card dl {
margin: 12px 0 0;
}
.port-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 6px 12px;
margin-top: 8px;
font-size: 12px;
}
.port-row dt {
color: var(--pm-text-secondary);
}
.port-row dd {
margin: 0;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@container config-main (max-width: 680px) {
.summary-grid {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@ -0,0 +1,22 @@
<template>
<section class="mail-port-summary" aria-label="邮件协议端口汇总">
<p class="summary-note">端口自动读取 SMTP、IMAP、POP3 配置,不能在邮箱策略中修改。这里显示已应用的配置值,不是端口在线检测;如有待重启提示,请重启后端后再连接。</p>
<div class="summary-grid">
<article v-for="service in services" :key="service.name" class="protocol-card">
<strong>{{ service.name }}</strong>
<dl>
<div v-for="port in service.ports" :key="port.label" class="port-row">
<dt>{{ port.label }}</dt>
<dd>{{ port.value ?? '未读取' }}</dd>
</div>
</dl>
</article>
</div>
</section>
</template>
<script setup>
defineProps({ services: { type: Array, required: true } })
</script>
<style scoped src="./MailPortSummary.css"></style>

View File

@ -0,0 +1,13 @@
<template>
<ConfigEditor />
</template>
<script setup>
import { provide } from 'vue'
import { usePage } from '../ConfigSettings.js'
import ConfigEditor from '../ConfigEditor/ConfigEditor.vue'
const page = usePage('pop3')
provide('smail-config-page', page)
defineExpose({ beforeLeave: page.beforeLeave })
</script>

View File

@ -0,0 +1,201 @@
package integration_test
import (
"bufio"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/listen/imap_server"
"git.pcux.cn/xuans/Smail/listen/pop3_server"
"git.pcux.cn/xuans/Smail/listen/smtp_server"
)
// 使用空白临时配置和临时证书,在非默认端口调用生产监听入口;不连接真实账号库。
func TestConfiguredMailListenerPortsAcceptProtocolConnections(t *testing.T) {
root := t.TempDir()
ports := reservePorts(t, 6)
meta := config.SectionMeta{Schema: config.CurrentSectionSchema, Generation: 1}
smtp := config.SMTPSectionConfig{Meta: meta, Listener: config.SMTPListenerConfig{
Port: ports[0], STARTTLSPort: ports[1], TLSPort: ports[2],
}}
email := config.EmailSectionConfig{Meta: meta, IMAP: config.IMAPListenerConfig{TLSPort: ports[3]}, POP3: config.POP3ListenerConfig{Port: ports[4], TLSPort: ports[5]}}
ssl := config.SSLSectionConfig{Meta: meta, TLS: config.SSLConfig{Mode: "default", DefaultDirectory: "config/ssl"}}
rootConfig := config.RootConfig{
ConfigVersion: config.CurrentConfigVersion, SchemaVersion: config.CurrentSchemaVersion, Generation: 1, SetupState: config.SetupStateBootstrap,
Database: config.DatabaseConfig{Type: config.DatabaseTypeSQLite, LocalRef: "unused.db"},
Domains: config.DomainConfig{Primary: "mail.example.com", Web: "mail.example.com", Accepted: []string{"mail.example.com"}},
Security: config.SecurityConfig{PasswordVersion: "argon2id"},
Sections: map[config.Section]config.SectionRef{},
}
directory := filepath.Join(root, "config")
if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatal(err)
}
for section, value := range map[config.Section]any{
config.SectionSMTP: smtp, config.SectionEmail: email, config.SectionSSL: ssl,
config.SectionPlugins: config.PluginsSectionConfig{Meta: meta},
} {
data, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
name, _ := config.FixedSectionFile(section)
if err := os.WriteFile(filepath.Join(directory, name), data, 0600); err != nil {
t.Fatal(err)
}
rootConfig.Sections[section] = config.SectionRef{File: name, Schema: 1, Generation: 1, SHA256: config.SectionDigest(data)}
}
data, err := json.Marshal(rootConfig)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "config.json"), data, 0600); err != nil {
t.Fatal(err)
}
manager, err := config.NewConfigManager(root, config.ConfigLoaderOptions{})
if err != nil {
t.Fatal(err)
}
previousManager, previousConfig, previousRoot := config.CurrentManager(), config.CurrentConfig(), config.ROOT_PATH
t.Cleanup(func() {
config.SetRuntimeManager(previousManager)
config.PublishConfigSnapshot(previousConfig)
config.ROOT_PATH = previousRoot
})
config.ROOT_PATH = root
config.SetRuntimeManager(manager)
certificate, key := writeCertificate(t, root)
config.PublishConfigSnapshot(&config.Config{SSLPublicKeyPath: certificate, SSLPrivateKeyPath: key})
cases := []struct {
name string
port int
tls bool
prefix string
start func() error
}{
{"smtp", ports[0], false, "220", smtp_server.Start},
{"smtp_starttls", ports[1], false, "220", smtp_server.StartWithTLSNew},
{"smtp_tls", ports[2], true, "220", smtp_server.StartWithTLS},
{"imap_tls", ports[3], true, "* OK", imap_server.StarTLS},
{"pop3_stls", ports[4], false, "+OK", pop3_server.Start},
{"pop3_tls", ports[5], true, "+OK", pop3_server.StartWithTls},
}
var results []<-chan error
t.Cleanup(func() {
smtp_server.Stop()
imap_server.Stop()
pop3_server.Stop()
for _, result := range results {
select {
case <-result:
case <-time.After(3 * time.Second):
t.Error("listener did not stop before configuration cleanup")
}
}
})
for _, item := range cases {
t.Run(item.name, func(t *testing.T) {
result := make(chan error, 1)
results = append(results, result)
go func() {
defer close(result)
result <- item.start()
}()
address := fmt.Sprintf("127.0.0.1:%d", item.port)
var conn net.Conn
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
select {
case err := <-result:
t.Fatalf("listener exited before connection: %v", err)
default:
}
dialer := &net.Dialer{Timeout: 200 * time.Millisecond}
if item.tls {
conn, err = tls.DialWithDialer(dialer, "tcp", address, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12})
} else {
conn, err = dialer.Dial("tcp", address)
}
if err == nil {
break
}
time.Sleep(25 * time.Millisecond)
}
if err != nil {
t.Fatalf("connect custom port: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
greeting, err := bufio.NewReader(conn).ReadString('\n')
if err != nil || !strings.HasPrefix(greeting, item.prefix) {
t.Fatalf("protocol greeting = %q, error = %v", greeting, err)
}
t.Logf("%s accepted protocol connection on custom port %d", item.name, item.port)
})
}
}
func reservePorts(t *testing.T, count int) []int {
t.Helper()
var ports []int
var listeners []net.Listener
defer func() {
for _, listener := range listeners {
_ = listener.Close()
}
}()
for range count {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
listeners = append(listeners, listener)
ports = append(ports, listener.Addr().(*net.TCPAddr).Port)
}
return ports
}
func writeCertificate(t *testing.T, root string) (string, string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost"},
NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
certificatePath, keyPath := filepath.Join(root, "certificate.pem"), filepath.Join(root, "key.pem")
for path, block := range map[string]*pem.Block{
certificatePath: {Type: "CERTIFICATE", Bytes: der}, keyPath: {Type: "PRIVATE KEY", Bytes: keyDER},
} {
if err := os.WriteFile(path, pem.EncodeToMemory(block), 0600); err != nil {
t.Fatal(err)
}
}
return certificatePath, keyPath
}

View File

@ -10,7 +10,7 @@ const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } =
source.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }'
)()
test('五个分区使用分组表单,控件和样式能编译且不再提供 JSON 编辑器', () => {
test('七个页面使用分组表单,控件和样式能编译且不再提供 JSON 编辑器', () => {
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
for (const name of ['ConfigForm', 'ConfigField', 'ConfigEditor']) {
@ -22,7 +22,7 @@ test('五个分区使用分组表单,控件和样式能编译且不再提供 J
assert.deepEqual(compileStyle({ source: css, id: `data-v-${name}`, scoped: true }).errors, [])
assert.doesNotMatch(template, /&#x[0-9a-f]+;|type="textarea"|配置 JSON 编辑器/i)
}
for (const section of ['base', 'smtp', 'email', 'ssl', 'plugins']) {
for (const section of ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']) {
const groups = getFieldGroups(section, { plugins: { spam_block: { settings: null } } })
assert.ok(groups.length)
const keys = groups.flatMap(group => group.fields.map(field => field.path.join('.')))
@ -32,6 +32,19 @@ test('五个分区使用分组表单,控件和样式能编译且不再提供 J
}
})
test('各协议仅编辑自己的监听端口,邮箱策略没有重复端口输入项', () => {
const config = { listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, relay: { relayPort: 465 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } }
const text = JSON.stringify(config)
assert.equal(updateFormConfig(text, 'email', ['policy', 'smtpPort'], 2525), text)
assert.equal(updateFormConfig(text, 'imap', ['pop3', 'port'], 1110), text)
assert.equal(updateFormConfig(text, 'pop3', ['imap', 'tlsPort'], 1993), text)
const edited = readFormConfig(updateFormConfig(text, 'smtp', ['listener', 'tlsPort'], 2465))
assert.equal(edited.listener.tlsPort, 2465)
assert.equal(edited.relay.relayPort, 465)
assert.deepEqual(getFieldGroups('email', config).flatMap(group => group.fields).filter(field => /port/i.test(field.path.join('.'))), [])
assert.ok(validateFormConfig('smtp', { ...config, listener: { ...config.listener, tlsPort: 587 } })['listener.tlsPort'])
})
test('文本输入的左右留白属于原生输入框,不影响数字、选择和开关控件', () => {
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileStyle } = require('@vue/compiler-sfc')
@ -151,9 +164,9 @@ test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正
assert.equal(readFormConfig(text).http.port, null)
assert.ok(validateFormConfig('base', readFormConfig(text))['http.port'])
for (const value of ['25', 0, -1, 65536, 1.5, null]) {
assert.ok(validateFormConfig('email', { policy: { smtpPort: value, imapPort: 143, pop3Port: 110 } })['policy.smtpPort'])
assert.ok(validateFormConfig('imap', { imap: { tlsPort: value } })['imap.tlsPort'])
}
assert.ok(validateFormConfig('email', { policy: { smtpPort: 143, imapPort: 143, pop3Port: 110 } })['policy.imapPort'])
assert.ok(validateFormConfig('pop3', { imap: { tlsPort: 993 }, pop3: { port: 993, tlsPort: 995 } })['pop3.port'])
assert.deepEqual(validateFormConfig('email', { policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } }), {})
})

View File

@ -5,7 +5,7 @@ import { compileFunction } from 'node:vm'
import { computed, reactive, ref } from './vue.mjs'
const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigNavigation.js', import.meta.url), 'utf8')
const components = ['BaseConfig', 'SmtpConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']
const components = ['BaseConfig', 'SmtpConfig', 'ImapConfig', 'Pop3Config', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']
const createNavigation = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function useNavigation', 'function useNavigation') + '\nreturn useNavigation()',
['computed', 'nextTick', 'ref', 'useGlobalStatusStore', ...components]
@ -18,9 +18,10 @@ function fixture(capabilities = ['config.read']) {
return { page, allowed }
}
test('五个菜单对应五个不同页面组件,无权限的维护入口不可打开', async () => {
test('七个菜单对应七个不同页面组件,无权限的维护入口不可打开', async () => {
const { page, allowed } = fixture()
assert.equal(new Set(page.sectionOptions.value.map(item => item.component)).size, 5)
assert.equal(new Set(page.sectionOptions.value.map(item => item.component)).size, 7)
assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins'])
assert.equal(page.maintenanceOptions.value.length, 0)
await page.selectSection('audit')
await page.selectSection('invalid')

View File

@ -219,9 +219,9 @@ test('同一页重复加载时,过期失败不覆盖最新请求', async () =>
assert.deepEqual(JSON.parse(page.editorText.value), { enabled: true })
})
test('五个配置页固定分区并按需加载,所有分区均提供中文说明', async () => {
test('七个配置页按需加载,协议页复用正确分区且提供中文说明', async () => {
const requests = []
for (const key of ['base', 'smtp', 'email', 'ssl', 'plugins']) {
for (const key of ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']) {
const { page } = fixture(async path => { requests.push(path); return { errorNo: 0, data: {} } }, key)
await page.loadAll()
assert.equal(page.selectedSection.value, key)
@ -230,8 +230,78 @@ test('五个配置页固定分区并按需加载,所有分区均提供中文
assert.equal(page.confirmed.value, false)
assert.equal(page.savedGeneration.value, 0)
}
assert.deepEqual(requests, ['base', 'smtp', 'email', 'ssl', 'plugins']
.flatMap(key => ['/api/v1/config/status', `/api/v1/config/${key}`]))
assert.deepEqual(requests, ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']
.flatMap(key => ['/api/v1/config/status', `/api/v1/config/${['imap', 'pop3'].includes(key) ? 'email' : key}`, ...(key === 'email' ? ['/api/v1/config/smtp'] : [])]))
})
for (const key of ['imap', 'pop3']) {
test(`${key} 页面验证、保存、候选恢复及应用均使用邮箱分区且保留其他字段`, async () => {
const requests = []
let pending = null
let active = { meta: { schema: 1, generation: 1 }, policy: { maxMessageBytes: 1234 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 } }
const { page } = fixture(() => {}, key, { http: {
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : structuredClone(active) }),
put: async (path, body) => {
requests.push(path)
assert.equal(body.expectedGeneration, 1)
assert.deepEqual(body.payload.policy, active.policy)
assert.deepEqual(body.payload[key === 'imap' ? 'pop3' : 'imap'], active[key === 'imap' ? 'pop3' : 'imap'])
pending = { generation: 2, candidateId: 'mail-candidate', section: 'email', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async (path, body) => {
requests.push(path)
if (path.endsWith('/apply')) {
assert.equal(body.candidateId, 'mail-candidate')
active = { ...pending.payload, meta: { schema: 1, generation: 2 } }
pending = null
}
return { errorNo: 0, data: { valid: true } }
}
} })
await page.loadAll()
page.updateField([key, 'tlsPort'], key === 'imap' ? 1993 : 1995)
await page.validateConfig()
await page.saveConfig()
await page.refreshPage()
assert.equal(page.savedGeneration.value, 2)
page.updateField([key, 'tlsPort'], 8888)
assert.notEqual(page.formConfig.value[key].tlsPort, 8888)
await page.applyConfig()
assert.equal(page.status.activeGeneration, 2)
assert.equal(page.savedGeneration.value, 0)
assert.deepEqual(requests, ['/api/v1/config/email/validate', '/api/v1/config/email', '/api/v1/config/email/apply'])
})
}
test('邮箱策略从三种协议配置汇总已应用端口,不能修改或被候选值误导', async () => {
const config = { meta: { generation: 3 }, policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 }, imap: { tlsPort: 1993 }, pop3: { port: 1110, tlsPort: 1995 } }
const { page } = fixture(() => {}, 'email', { http: { get: async path => ({ errorNo: 0, data:
path.endsWith('/status') ? { activeGeneration: 3 } : path.endsWith('/smtp') ? { meta: { generation: 3 }, listener: { port: 2525, starttlsPort: 2587, tlsPort: 2465 }, relay: { relayPort: 9999 } } :
path.endsWith('/pending') ? { section: 'email', generation: 4, sameSession: true, payload: { ...config, imap: { tlsPort: 2993 } } } : config
}) } })
await page.loadAll()
assert.deepEqual(page.mailPortSummary.value.flatMap(service => service.ports.map(port => port.value)), [2525, 2587, 2465, 1993, 1110, 1995])
assert.equal(page.formConfig.value.imap.tlsPort, 2993)
const before = page.editorText.value
for (const path of [['policy', 'smtpPort'], ['policy', 'imapPort'], ['imap', 'tlsPort']]) page.updateField(path, 8888)
assert.equal(page.editorText.value, before)
})
test('邮箱策略拒绝不同代次的端口汇总,后续加载失败清除旧汇总', async () => {
let generation = 2
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/smtp')
? { meta: { generation }, listener: { port: 2525 } }
: { meta: { generation: 1 }, imap: { tlsPort: 1993 }, pop3: { port: 1110, tlsPort: 1995 } } }), 'email')
await page.loadSection()
assert.match(page.sectionError.value, /代次/)
assert.equal(page.mailPortSummary.value, null)
generation = 1
await page.loadSection()
assert.ok(page.mailPortSummary.value)
generation = 3
await page.loadSection()
assert.equal(page.mailPortSummary.value, null)
})
test('迁移预览票据返回模板,迁移执行取消时不发送写入请求', async () => {
@ -312,7 +382,7 @@ test('独立页模板可编译,编辑器不再连接维护面板且权限绑
assert.ok(maintenance.includes(`pageKey === '${key}' && ${capability}`))
}
assert.match(maintenance, /v-if="canExportAudit"/)
for (const name of ['BaseConfig', 'SmtpConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']) {
for (const name of ['BaseConfig', 'SmtpConfig', 'ImapConfig', 'Pop3Config', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance', 'MailPortSummary']) {
const page = readFileSync(new URL(`${name}/${name}.vue`, root), 'utf8')
const { descriptor, errors } = parse(page)
assert.deepEqual(errors, [])
@ -496,16 +566,16 @@ test('可视化编辑按权限和工作状态保护,编辑会清除过期确
test('前端无效字段阻止网络请求,修正字段后同步清除错误', async () => {
const requests = []
const { page } = fixture(async () => ({ errorNo: 0, data: { policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } } }), 'email', {
const { page } = fixture(async () => ({ errorNo: 0, data: { imap: { tlsPort: 993 } } }), 'imap', {
http: { post: async path => { requests.push(path); return { errorNo: 0, data: { valid: true } } } }
})
await page.loadAll()
page.updateField(['policy', 'smtpPort'], null)
page.updateField(['imap', 'tlsPort'], null)
await page.validateConfig()
await page.saveConfig()
assert.deepEqual(requests, [])
assert.ok(page.formErrors.value['policy.smtpPort'])
page.updateField(['policy', 'smtpPort'], 2525)
assert.ok(page.formErrors.value['imap.tlsPort'])
page.updateField(['imap', 'tlsPort'], 1993)
assert.deepEqual(page.formErrors.value, {})
await page.validateConfig()
assert.deepEqual(requests, ['/api/v1/config/email/validate'])
@ -595,3 +665,22 @@ test('保存后更改密码或填写内容不能应用旧候选', async () => {
assert.equal(page.savedGeneration.value, 0)
assert.deepEqual(requests, [])
})
test('共用邮箱分区的候选明确提示其他页面的改动,忽略代次与字段顺序', async () => {
const active = { meta: { generation: 1 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { maxMessageBytes: 1234 } }
for (const key of ['imap', 'pop3', 'email']) {
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/smtp') ? { meta: { generation: 1 }, listener: {} } : active }), key)
await page.loadSection()
page.pendingCandidate.value = { section: 'email', payload: { ...active, meta: { generation: 2 }, pop3: { tlsPort: 995, port: 110 } } }
assert.match(page.pendingScopeHint.value, /协议端口和邮箱策略未变化/)
page.pendingCandidate.value.payload = { ...active, pop3: { port: 1110, tlsPort: 1995 }, policy: { maxMessageBytes: 5678 } }
assert.match(page.pendingScopeHint.value, /本次候选涉及:POP3 配置、邮箱策略。/)
assert.match(page.pendingScopeHint.value, /请逐页核对;应用时会一起生效/)
page.pendingCandidate.value = { section: 'smtp' }
assert.equal(page.pendingScopeHint.value, '')
page.pendingCandidate.value = { section: 'email' }
assert.match(page.pendingScopeHint.value, /候选内容尚未完整读取/)
}
const template = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue', import.meta.url), 'utf8')
assert.match(template, /:title="pendingScopeHint"/)
})