2026-08-12 12:56:22 +01:00
{{define "title"}}Server Settings - Email Server{{end}}
{{define "extra_css"}}
< style >
. setting-section { border-left : 4 px solid var ( -- bs - primary ); padding-left : 1 rem ; margin-bottom : 2 rem ; }
. setting-description { font-size : 0.875 rem ; color : var ( -- bs - secondary ); margin-bottom : 0.5 rem ; }
</ style >
{{end}}
{{define "content"}}
< div class = "container-fluid" >
< div class = "d-flex justify-content-between align-items-center mb-4" >
< h2 >< i class = "bi bi-sliders me-2" ></ i > Server Settings</ h2 >
< div class = "btn-group" >
< button type = "button" class = "btn btn-outline-info" onclick = "exportSettings()" >< i class = "bi bi-download me-2" ></ i > Export Config</ button >
</ div >
</ div >
< form method = "POST" action = "/pymta-manager/settings_update" id = "settingsForm" >
< div class = "card mb-4" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-server me-2" ></ i > Server Configuration</ h5 ></ div >
< div class = "card-body" >
< div class = "setting-section" >
< div class = "row" >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > SMTP Port</ label >
< div class = "setting-description" > Port for plain/IP-whitelisted SMTP connections</ div >
< input type = "number" class = "form-control" name = "Server.smtp_port" value = "{{.settings.Server.smtp_port}}" min = "1" max = "65535" >
</ div ></ div >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > SMTP TLS Port</ label >
< div class = "setting-description" > Port for direct-TLS authenticated SMTP connections</ div >
< input type = "number" class = "form-control" name = "Server.smtp_tls_port" value = "{{.settings.Server.smtp_tls_port}}" min = "1" max = "65535" >
</ div ></ div >
</ div >
2026-08-13 07:03:40 +01:00
< div class = "row" >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > Admin UI HTTP Port</ label >
< div class = "setting-description" > Plain HTTP port for this admin web interface</ div >
< input type = "number" class = "form-control" name = "Server.web_http_port" value = "{{.settings.Server.web_http_port}}" min = "1" max = "65535" >
</ div ></ div >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > Admin UI HTTPS Port</ label >
< div class = "setting-description" > Self-signed by default, or the Let's Encrypt cert once enabled</ div >
< input type = "number" class = "form-control" name = "Server.web_https_port" value = "{{.settings.Server.web_https_port}}" min = "1" max = "65535" >
</ div ></ div >
</ div >
2026-08-12 12:56:22 +01:00
< div class = "row" >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > Bind IP Address</ label >
< input type = "text" class = "form-control" name = "Server.bind_ip" value = "{{.settings.Server.bind_ip}}" >
</ div ></ div >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > Server Timezone</ label >
< select class = "form-select" name = "Server.time_zone" >
{{$currentTZ := .settings.Server.time_zone}}
{{range .timezones}}< option value = "{{.}}" {{ if eq . $ currentTZ }} selected {{ end }} > {{.}}</ option > {{end}}
</ select >
</ div ></ div >
</ div >
< div class = "row" >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > Hostname</ label >
< input type = "text" class = "form-control" name = "Server.hostname" value = "{{.settings.Server.hostname}}" >
</ div ></ div >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > HELO Hostname</ label >
< input type = "text" class = "form-control" name = "Server.helo_hostname" value = "{{.settings.Server.helo_hostname}}" >
</ div ></ div >
</ div >
< div class = "row" >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > Server Banner</ label >
< div class = "setting-description" > Custom SMTP banner (empty by default)</ div >
< input type = "text" class = "form-control" name = "Server.server_banner" value = "{{.settings.Server.server_banner}}" >
</ div ></ div >
</ div >
</ div >
</ div >
</ div >
< div class = "card mb-4" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-database me-2" ></ i > Database Configuration</ h5 ></ div >
< div class = "card-body" >
< div class = "setting-section" >
< div class = "mb-3" >
< label class = "form-label" > Database URL</ label >
< div class = "input-group mb-2" >
< input type = "text" class = "form-control font-monospace" name = "Database.database_url" id = "databaseUrl" value = "{{.settings.Database.database_url}}" >
< button class = "btn btn-primary" type = "button" onclick = "testDatabaseConnection()" >< i class = "bi bi-check-circle me-1" ></ i > Test Connection</ button >
</ div >
</ div >
</ div >
</ div >
</ div >
< div class = "card mb-4" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-journal-text me-2" ></ i > Logging Configuration</ h5 ></ div >
< div class = "card-body" >
< div class = "setting-section" >
< div class = "row" >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > Log Level</ label >
< select class = "form-select" name = "Logging.log_level" >
{{$lvl := .settings.Logging.log_level}}
{{range (list "DEBUG" "INFO" "WARNING" "ERROR" "CRITICAL")}}< option value = "{{.}}" {{ if eq . $ lvl }} selected {{ end }} > {{.}}</ option > {{end}}
</ select >
</ div ></ div >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > Hide aiosmtpd-equivalent INFO Messages</ label >
< select class = "form-select" name = "Logging.hide_info_aiosmtpd" >
< option value = "true" {{ if eq . settings . Logging . hide_info_aiosmtpd " true "}} selected {{ end }} > Yes</ option >
< option value = "false" {{ if eq . settings . Logging . hide_info_aiosmtpd " false "}} selected {{ end }} > No</ option >
</ select >
</ div ></ div >
</ div >
</ div >
</ div >
</ div >
< div class = "card mb-4" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-arrow-repeat me-2" ></ i > Email Relay Configuration</ h5 ></ div >
< div class = "card-body" >
< div class = "setting-section" >
< div class = "mb-3" >< label class = "form-label" > Relay Timeout (seconds)</ label >
< input type = "number" class = "form-control" name = "Relay.relay_timeout" value = "{{.settings.Relay.relay_timeout}}" min = "5" max = "300" >
</ div >
</ div >
</ div >
</ div >
2026-08-13 07:03:40 +01:00
< div class = "card mb-4" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-inbox me-2" ></ i > IMAP Configuration</ h5 ></ div >
< div class = "card-body" >
< div class = "setting-section" >
< div class = "row" >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > IMAP Port</ label >
< div class = "setting-description" > Plain IMAP port (no STARTTLS offered)</ div >
< input type = "number" class = "form-control" name = "IMAP.imap_port" value = "{{.settings.IMAP.imap_port}}" min = "1" max = "65535" >
</ div ></ div >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > IMAP TLS Port</ label >
< div class = "setting-description" > Implicit-TLS IMAP port (IMAPS)</ div >
< input type = "number" class = "form-control" name = "IMAP.imap_tls_port" value = "{{.settings.IMAP.imap_tls_port}}" min = "1" max = "65535" >
</ div ></ div >
</ div >
</ div >
</ div >
</ div >
< div class = "card mb-4" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-shield-exclamation me-2" ></ i > Rspamd (Spam Scoring)</ h5 ></ div >
< div class = "card-body" >
< div class = "setting-section" >
< div class = "setting-description" > Optional; the built-in heuristic spam score always runs regardless of this setting.</ div >
< div class = "row" >
< div class = "col-md-4" >< div class = "mb-3" >< label class = "form-label" > Enabled</ label >
< select class = "form-select" name = "Rspamd.enabled" >
< option value = "true" {{ if eq . settings . Rspamd . enabled " true "}} selected {{ end }} > Yes</ option >
< option value = "false" {{ if eq . settings . Rspamd . enabled " false "}} selected {{ end }} > No</ option >
</ select >
</ div ></ div >
< div class = "col-md-4" >< div class = "mb-3" >< label class = "form-label" > Rspamd URL</ label >
< input type = "text" class = "form-control" name = "Rspamd.url" value = "{{.settings.Rspamd.url}}" placeholder = "http://127.0.0.1:11333" >
</ div ></ div >
< div class = "col-md-4" >< div class = "mb-3" >< label class = "form-label" > Reject Score</ label >
< input type = "number" class = "form-control" name = "Rspamd.reject_score" value = "{{.settings.Rspamd.reject_score}}" min = "1" >
</ div ></ div >
</ div >
</ div >
</ div >
2026-08-13 08:07:19 +01:00
</ div >
< div class = "card mb-4" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-shield-check me-2" ></ i > Two-Factor Authentication (MFA)</ h5 ></ div >
< div class = "card-body" >
< div class = "setting-section" >
< div class = "row" >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > Enforce MFA for Admins</ label >
< div class = "setting-description" > Require every admin account (global or domain-scoped) to set up TOTP/passkey MFA before using the dashboard</ div >
< select class = "form-select" name = "Auth.enforce_admin_mfa" >
< option value = "true" {{ if eq . settings . Auth . enforce_admin_mfa " true "}} selected {{ end }} > Yes</ option >
< option value = "false" {{ if eq . settings . Auth . enforce_admin_mfa " false "}} selected {{ end }} > No</ option >
</ select >
</ div ></ div >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > Enforce MFA for Mailboxes</ label >
< div class = "setting-description" > Require MFA before a mailbox owner can create new app passwords in the self-service portal — overridable per-domain or per-mailbox</ div >
< select class = "form-select" name = "Auth.enforce_mailbox_mfa" >
< option value = "true" {{ if eq . settings . Auth . enforce_mailbox_mfa " true "}} selected {{ end }} > Yes</ option >
< option value = "false" {{ if eq . settings . Auth . enforce_mailbox_mfa " false "}} selected {{ end }} > No</ option >
</ select >
</ div ></ div >
</ div >
</ div >
</ div >
2026-08-13 07:03:40 +01:00
</ div >
2026-08-12 12:56:22 +01:00
< div class = "card mb-4" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-shield-lock me-2" ></ i > TLS/SSL Configuration</ h5 ></ div >
< div class = "card-body" >
< div class = "setting-section" >
< div class = "row" >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > TLS Certificate File</ label >
< div class = "input-group" >
< input type = "text" class = "form-control font-monospace" name = "TLS.tls_cert_file" value = "{{.settings.TLS.tls_cert_file}}" >
< input type = "file" class = "d-none" id = "certFileUpload" accept = ".crt,.pem" >
< button class = "btn btn-outline-secondary" type = "button" onclick = "document.getElementById('certFileUpload').click()" >< i class = "bi bi-upload" ></ i ></ button >
</ div >
</ div ></ div >
< div class = "col-md-6" >< div class = "mb-3" >< label class = "form-label" > TLS Private Key File</ label >
< div class = "input-group" >
< input type = "text" class = "form-control font-monospace" name = "TLS.tls_key_file" value = "{{.settings.TLS.tls_key_file}}" >
< input type = "file" class = "d-none" id = "keyFileUpload" accept = ".key,.pem" >
< button class = "btn btn-outline-secondary" type = "button" onclick = "document.getElementById('keyFileUpload').click()" >< i class = "bi bi-upload" ></ i ></ button >
</ div >
</ div ></ div >
</ div >
</ div >
</ div >
</ div >
< div class = "card mb-4" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-key me-2" ></ i > DKIM Configuration</ h5 ></ div >
< div class = "card-body" >
< div class = "setting-section" >
< div class = "mb-3" >< label class = "form-label" > DKIM Key Size</ label >
< select class = "form-select" name = "DKIM.dkim_key_size" >
{{$ks := .settings.DKIM.dkim_key_size}}
< option value = "1024" {{ if eq $ ks " 1024 "}} selected {{ end }} > 1024 bits</ option >
< option value = "2048" {{ if eq $ ks " 2048 "}} selected {{ end }} > 2048 bits (Recommended)</ option >
< option value = "4096" {{ if eq $ ks " 4096 "}} selected {{ end }} > 4096 bits</ option >
</ select >
</ div >
< div class = "mb-3" >< label class = "form-label" > SPF Server IP</ label >
< div class = "input-group" >
< input type = "text" class = "form-control" name = "DKIM.spf_server_ip" value = "{{.settings.DKIM.spf_server_ip}}" >
< button class = "btn btn-danger" type = "button" onclick = "getPublicIP()" >< i class = "bi bi-cloud-download me-1" ></ i > Get Public IP</ button >
</ div >
</ div >
</ div >
</ div >
</ div >
< div class = "card mb-4" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-paperclip me-2" ></ i > Attachments Configuration</ h5 ></ div >
< div class = "card-body" >
< div class = "setting-section" >
< div class = "mb-3" >< label class = "form-label" > Attachments Storage Path</ label >
< input type = "text" class = "form-control" name = "Attachments.attachments_path" value = "{{.settings.Attachments.attachments_path}}" placeholder = "server_data/attachments" >
< div class = "setting-description text-warning" >< i class = "bi bi-exclamation-triangle me-1" ></ i > Make sure the path exists and is writable by the server process</ div >
< div id = "attachments-path-feedback" class = "mt-2" ></ div >
</ div >
</ div >
</ div >
</ div >
< div class = "d-flex justify-content-between align-items-center" >
< div class = "alert alert-warning d-flex align-items-center mb-0" >< i class = "bi bi-exclamation-triangle me-2" ></ i >< small > Server restart required after changing settings</ small ></ div >
< button type = "submit" class = "btn btn-primary btn-lg" >< i class = "bi bi-save me-2" ></ i > Save Settings</ button >
</ div >
</ form >
</ div >
{{end}}
{{define "extra_js"}}
< script >
function exportSettings () {
const settings = {};
const formData = new FormData ( document . querySelector ( 'form' ));
for ( let [ key , value ] of formData . entries ()) {
const [ section , setting ] = key . split ( '.' );
if ( ! settings [ section ]) { settings [ section ] = {}; }
settings [ section ][ setting ] = value ;
}
let config = '' ;
for ( const [ section , values ] of Object . entries ( settings )) {
config += `[ ${ section } ]\n` ;
for ( const [ key , value ] of Object . entries ( values )) { config += ` ${ key } = ${ value } \n` ; }
config += '\n' ;
}
const element = document . createElement ( 'a' );
element . setAttribute ( 'href' , 'data:text/plain;charset=utf-8,' + encodeURIComponent ( config ));
element . setAttribute ( 'download' , 'settings.ini' );
element . style . display = 'none' ;
document . body . appendChild ( element );
element . click ();
document . body . removeChild ( element );
}
document . querySelector ( 'form' ). addEventListener ( 'submit' , function ( e ) {
2026-08-13 07:03:40 +01:00
const ports = [ 'Server.smtp_port' , 'Server.smtp_tls_port' , 'Server.web_http_port' , 'Server.web_https_port' , 'IMAP.imap_port' , 'IMAP.imap_tls_port' ];
const seen = {};
2026-08-12 12:56:22 +01:00
for ( const portField of ports ) {
const input = document . querySelector ( `[name=" ${ portField } "]` );
const port = parseInt ( input . value );
if ( port < 1 || port > 65535 ) { e . preventDefault (); showToast ( `Invalid port number: ${ port } .` , 'danger' ); input . focus (); return ; }
2026-08-13 07:03:40 +01:00
if ( seen [ port ]) { e . preventDefault (); showToast ( `Port ${ port } is used more than once — each port must be different.` , 'danger' ); input . focus (); return ; }
seen [ port ] = true ;
2026-08-12 12:56:22 +01:00
}
const serverBanner = document . querySelector ( '[name="Server.server_banner"]' );
if ( serverBanner && ! serverBanner . value . trim ()) { serverBanner . value = '""' ; }
const attachmentsPath = document . querySelector ( 'input[name="Attachments.attachments_path"]' );
if ( ! attachmentsPath . value . trim ()) { e . preventDefault (); showToast ( 'Please specify a valid attachments storage path' , 'danger' ); attachmentsPath . focus (); }
});
document . getElementById ( 'certFileUpload' ). addEventListener ( 'change' , function ( e ) {
const file = e . target . files [ 0 ];
if ( ! file ) return ;
const formData = new FormData ();
formData . append ( 'cert_file' , file );
fetch ( '/pymta-manager/api/settings/upload_cert' , { method : 'POST' , body : formData })
. then ( r => r . json ())
. then ( data => {
if ( data . status === 'success' ) { document . querySelector ( '[name="TLS.tls_cert_file"]' ). value = data . filepath ; showToast ( 'Certificate uploaded' , 'success' ); }
else { showToast ( data . message || 'Failed to upload certificate' , 'danger' ); }
}). catch (() => showToast ( 'Failed to upload certificate' , 'danger' ));
});
document . getElementById ( 'keyFileUpload' ). addEventListener ( 'change' , function ( e ) {
const file = e . target . files [ 0 ];
if ( ! file ) return ;
const formData = new FormData ();
formData . append ( 'key_file' , file );
fetch ( '/pymta-manager/api/settings/upload_key' , { method : 'POST' , body : formData })
. then ( r => r . json ())
. then ( data => {
if ( data . status === 'success' ) { document . querySelector ( '[name="TLS.tls_key_file"]' ). value = data . filepath ; showToast ( 'Key uploaded' , 'success' ); }
else { showToast ( data . message || 'Failed to upload key' , 'danger' ); }
}). catch (() => showToast ( 'Failed to upload key' , 'danger' ));
});
function testDatabaseConnection () {
const url = document . getElementById ( 'databaseUrl' ). value ;
fetch ( '/pymta-manager/api/settings/test_database' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ({ url }) })
. then ( r => r . json ())
. then ( data => showToast ( data . message || ( data . status === 'success' ? 'Connection successful!' : 'Failed to connect' ), data . status === 'success' ? 'success' : 'danger' ))
. catch (() => showToast ( 'Failed to test database connection' , 'danger' ));
}
function getPublicIP () {
fetch ( '/pymta-manager/api/settings/get_public_ip' )
. then ( r => r . json ())
. then ( data => {
if ( data . ip ) { document . querySelector ( '[name="DKIM.spf_server_ip"]' ). value = data . ip ; showToast ( 'Public IP fetched' , 'success' ); }
else { showToast ( 'Failed to fetch public IP' , 'danger' ); }
}). catch (() => showToast ( 'Failed to fetch public IP' , 'danger' ));
}
function validateAttachmentsPath () {
const path = document . querySelector ( 'input[name="Attachments.attachments_path"]' ). value ;
const feedback = document . getElementById ( 'attachments-path-feedback' );
if ( ! feedback ) return ;
fetch ( '/pymta-manager/test_attachments_path' , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' }, body : `path= ${ encodeURIComponent ( path ) } ` })
. then ( r => r . json ())
. then ( data => {
feedback . innerHTML = data . message + ( data . success ? `<br><small class="text-muted">Absolute path: ${ data . absolute_path } </small>` : '' );
feedback . className = data . success ? 'text-success mt-2' : 'text-danger mt-2' ;
}). catch ( error => { feedback . innerHTML = `Error validating path: ${ error } ` ; feedback . className = 'text-danger mt-2' ; });
}
document . querySelector ( 'input[name="Attachments.attachments_path"]' ) ? . addEventListener ( 'change' , validateAttachmentsPath );
</ script >
{{end}}