补齐IMAP和POP3配置并统一邮件协议监听端口
This commit is contained in:
parent
9b60eb2177
commit
24829c1199
48
backend/config/mail_listeners.go
Normal file
48
backend/config/mail_listeners.go
Normal 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
|
||||
}
|
||||
148
backend/config/mail_listeners_test.go
Normal file
148
backend/config/mail_listeners_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
49
backend/controllers/config_mail_ports_test.go
Normal file
49
backend/controllers/config_mail_ports_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
34
docs/邮件协议监听配置说明.md
Normal file
34
docs/邮件协议监听配置说明.md
Normal 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 配置文件。
|
||||
@ -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,
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
201
test/backend/mail_listeners_test.go
Normal file
201
test/backend/mail_listeners_test.go
Normal 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
|
||||
}
|
||||
@ -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 } }), {})
|
||||
})
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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"/)
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user