2026-08-12 21:14:19 +01:00
{{define "title"}}Let's Encrypt - Email Server Management{{end}}
{{define "page_title"}}Let's Encrypt{{end}}
{{define "content"}}
< div class = "d-flex justify-content-between align-items-center mb-4" >
< h2 >< i class = "bi bi-patch-check me-2" ></ i > Let's Encrypt</ h2 >
</ div >
2026-08-15 12:35:44 +01:00
< p class = "text-muted" >
Two independent certificates can be obtained here — DNS-01 (needs a supported DNS provider) and
HTTP-01 (needs nothing but port 80 reachable from the internet). Enable either, both, or
neither. Which listener (SMTP-TLS, IMAP-TLS, or the admin/webmail HTTPS) actually uses which
certificate is chosen on the < a href = "/pymta-manager/settings" > Settings</ a > page's TLS/SSL
section — e.g. run the HTTP-01 cert on mail while the dashboard keeps a DNS-01 or custom cert.
</ p >
2026-08-12 21:14:19 +01:00
< div class = "card mb-4" >
2026-08-15 12:35:44 +01:00
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-globe me-2" ></ i > DNS-01</ h5 ></ div >
2026-08-12 21:14:19 +01:00
< div class = "card-body" >
< dl class = "row mb-3" >
< dt class = "col-sm-3" > Mode</ dt >
< dd class = "col-sm-9" >
{{if .status.Enabled}}
2026-08-15 12:35:44 +01:00
< span class = "badge bg-success" >< i class = "bi bi-check-circle me-1" ></ i > Enabled {{if .status.Staging}}(staging){{end}}</ span >
2026-08-12 21:14:19 +01:00
{{else}}
2026-08-15 12:35:44 +01:00
< span class = "badge bg-secondary" >< i class = "bi bi-dash-circle me-1" ></ i > Disabled</ span >
2026-08-12 21:14:19 +01:00
{{end}}
</ dd >
< dt class = "col-sm-3" > Domains</ dt >
< dd class = "col-sm-9" > {{if .status.Domains}}{{range .status.Domains}}< code > {{.}}</ code > {{end}}{{else}}< span class = "text-muted" > none configured</ span > {{end}}</ dd >
2026-08-15 12:35:44 +01:00
< dt class = "col-sm-3" > DNS Provider</ dt >
2026-08-12 21:14:19 +01:00
< dd class = "col-sm-9" > {{if .status.Provider}}{{.status.Provider}}{{else}}< span class = "text-muted" > none selected</ span > {{end}}</ dd >
< dt class = "col-sm-3" > Certificate expires</ dt >
< dd class = "col-sm-9" > {{if .status.NotAfter.IsZero}}< span class = "text-muted" > unknown</ span > {{else}}{{strftime "%Y-%m-%d %H:%M" .status.NotAfter}}{{end}}</ dd >
< dt class = "col-sm-3" > Last attempt</ dt >
< dd class = "col-sm-9" >
{{if .status.LastAttempt.IsZero}}
< span class = "text-muted" > none yet this run</ span >
{{else if .status.LastError}}
< span class = "text-danger" >< i class = "bi bi-exclamation-triangle me-1" ></ i > {{strftime "%Y-%m-%d %H:%M" .status.LastAttempt}} — {{.status.LastError}}</ span >
{{else}}
< span class = "text-success" >< i class = "bi bi-check-circle me-1" ></ i > {{strftime "%Y-%m-%d %H:%M" .status.LastAttempt}} — success</ span >
{{end}}
</ dd >
</ dl >
< form method = "post" action = "/pymta-manager/letsencrypt/obtain" >
2026-08-15 12:35:44 +01:00
< button type = "submit" class = "btn btn-primary" data-confirm = "Obtain or renew the DNS-01 certificate now using the saved configuration?" >< i class = "bi bi-arrow-repeat me-1" ></ i > Obtain / Renew Now</ button >
</ form >
< hr >
< form method = "POST" action = "/pymta-manager/letsencrypt/save" >
< div class = "mb-3" >
< label class = "form-label" > Enable DNS-01</ label >
< select class = "form-select" name = "enabled" >
< option value = "false" {{ if ne . le . enabled " true "}} selected {{ end }} > No</ option >
< option value = "true" {{ if eq . le . enabled " true "}} selected {{ end }} > Yes</ option >
</ select >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Staging mode</ label >
< select class = "form-select" name = "staging" >
< option value = "false" {{ if ne . le . staging " true "}} selected {{ end }} > No — request a real, trusted certificate</ option >
< option value = "true" {{ if eq . le . staging " true "}} selected {{ end }} > Yes — untrusted test certificate, no rate limits</ option >
</ select >
< div class = "form-text" > Recommended while testing a new configuration.</ div >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Contact Email</ label >
< input type = "email" class = "form-control" name = "contact_email" value = "{{.le.contact_email}}" >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Domains</ label >
< input type = "text" class = "form-control font-monospace" name = "domains" value = "{{.le.domains}}" placeholder = "mail.example.com,*.mail.example.com" >
< div class = "form-text" > Comma-separated. Include a wildcard entry (e.g. < code > *.mail.example.com</ code > ) alongside its bare domain to cover both with one certificate.</ div >
</ div >
< div class = "mb-3" >
< label class = "form-label" > DNS Provider</ label >
< select class = "form-select" name = "dns_provider" id = "le_provider" >
< option value = "" > Select a provider...</ option >
< option value = "cloudflare" {{ if eq . le . dns_provider " cloudflare "}} selected {{ end }} > Cloudflare</ option >
< option value = "route53" {{ if eq . le . dns_provider " route53 "}} selected {{ end }} > AWS Route53</ option >
< option value = "digitalocean" {{ if eq . le . dns_provider " digitalocean "}} selected {{ end }} > DigitalOcean</ option >
< option value = "gcloud" {{ if eq . le . dns_provider " gcloud "}} selected {{ end }} > Google Cloud DNS</ option >
</ select >
</ div >
< div class = "provider-fields" id = "fields-cloudflare" >
< div class = "setting-section mb-3" >
< h6 > Cloudflare</ h6 >
< div class = "mb-3" >
< label class = "form-label" > API Token</ label >
< input type = "password" class = "form-control" name = "cloudflare_api_token" placeholder = "Leave blank to keep the current value" >
</ div >
</ div >
</ div >
< div class = "provider-fields" id = "fields-route53" >
< div class = "setting-section mb-3" >
< h6 > AWS Route53</ h6 >
< div class = "mb-3" >
< label class = "form-label" > Access Key ID</ label >
< input type = "password" class = "form-control" name = "route53_access_key_id" placeholder = "Leave blank to keep the current value, or blank both keys to use the host's AWS credential chain" >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Secret Access Key</ label >
< input type = "password" class = "form-control" name = "route53_secret_access_key" placeholder = "Leave blank to keep the current value" >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Region</ label >
< input type = "text" class = "form-control" name = "route53_region" value = "{{.le.route53_region}}" placeholder = "us-east-1" >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Hosted Zone ID (optional)</ label >
< input type = "text" class = "form-control" name = "route53_hosted_zone_id" value = "{{.le.route53_hosted_zone_id}}" placeholder = "Leave blank to auto-discover" >
</ div >
</ div >
</ div >
< div class = "provider-fields" id = "fields-digitalocean" >
< div class = "setting-section mb-3" >
< h6 > DigitalOcean</ h6 >
< div class = "mb-3" >
< label class = "form-label" > API Token</ label >
< input type = "password" class = "form-control" name = "digitalocean_api_token" placeholder = "Leave blank to keep the current value" >
</ div >
</ div >
</ div >
< div class = "provider-fields" id = "fields-gcloud" >
< div class = "setting-section mb-3" >
< h6 > Google Cloud DNS</ h6 >
< div class = "mb-3" >
< label class = "form-label" > Project ID</ label >
< input type = "text" class = "form-control" name = "gcloud_project" value = "{{.le.gcloud_project}}" >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Service Account Key (optional)</ label >
< div class = "input-group" >
< input type = "text" class = "form-control font-monospace" name = "gcloud_service_account_json_path" id = "gcloud_sa_path" placeholder = "Leave blank to use Application Default Credentials" >
< input type = "file" class = "d-none" id = "gcloudKeyUpload" accept = ".json" >
< button class = "btn btn-outline-secondary" type = "button" onclick = "document.getElementById('gcloudKeyUpload').click()" >< i class = "bi bi-upload" ></ i ></ button >
</ div >
</ div >
</ div >
</ div >
< button type = "submit" class = "btn btn-success" >< i class = "bi bi-check-lg me-1" ></ i > Save DNS-01 Configuration</ button >
2026-08-12 21:14:19 +01:00
</ form >
</ div >
</ div >
< div class = "card mb-4" >
2026-08-15 12:35:44 +01:00
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-hdd-network me-2" ></ i > HTTP-01</ h5 ></ div >
2026-08-12 21:14:19 +01:00
< div class = "card-body" >
2026-08-15 12:35:44 +01:00
< dl class = "row mb-3" >
< dt class = "col-sm-3" > Mode</ dt >
< dd class = "col-sm-9" >
{{if .statusHTTP.Enabled}}
< span class = "badge bg-success" >< i class = "bi bi-check-circle me-1" ></ i > Enabled</ span >
{{else}}
< span class = "badge bg-secondary" >< i class = "bi bi-dash-circle me-1" ></ i > Disabled</ span >
{{end}}
</ dd >
< dt class = "col-sm-3" > Domains</ dt >
< dd class = "col-sm-9" > {{if .statusHTTP.Domains}}{{range .statusHTTP.Domains}}< code > {{.}}</ code > {{end}}{{else}}< span class = "text-muted" > none configured</ span > {{end}}{{if .statusHTTP.IncludeIP}} < span class = "badge bg-info" > + server IP</ span > {{end}}</ dd >
< dt class = "col-sm-3" > Certificate expires</ dt >
< dd class = "col-sm-9" > {{if .statusHTTP.NotAfter.IsZero}}< span class = "text-muted" > unknown</ span > {{else}}{{strftime "%Y-%m-%d %H:%M" .statusHTTP.NotAfter}}{{end}}</ dd >
< dt class = "col-sm-3" > Last attempt</ dt >
< dd class = "col-sm-9" >
{{if .statusHTTP.LastAttempt.IsZero}}
< span class = "text-muted" > none yet this run</ span >
{{else if .statusHTTP.LastError}}
< span class = "text-danger" >< i class = "bi bi-exclamation-triangle me-1" ></ i > {{strftime "%Y-%m-%d %H:%M" .statusHTTP.LastAttempt}} — {{.statusHTTP.LastError}}</ span >
{{else}}
< span class = "text-success" >< i class = "bi bi-check-circle me-1" ></ i > {{strftime "%Y-%m-%d %H:%M" .statusHTTP.LastAttempt}} — success</ span >
{{end}}
</ dd >
</ dl >
< form method = "post" action = "/pymta-manager/letsencrypt/http/obtain" >
< button type = "submit" class = "btn btn-primary" data-confirm = "Obtain or renew the HTTP-01 certificate now using the saved configuration?" >< i class = "bi bi-arrow-repeat me-1" ></ i > Obtain / Renew Now</ button >
</ form >
2026-08-12 21:14:19 +01:00
2026-08-15 12:35:44 +01:00
< hr >
< form method = "POST" action = "/pymta-manager/letsencrypt/http/save" >
< div class = "mb-3" >
< label class = "form-label" > Enable HTTP-01</ label >
< select class = "form-select" name = "enabled" >
< option value = "false" {{ if ne . leHTTP . enabled " true "}} selected {{ end }} > No</ option >
< option value = "true" {{ if eq . leHTTP . enabled " true "}} selected {{ end }} > Yes</ option >
</ select >
< div class = "form-text" > No DNS provider needed — Let's Encrypt verifies ownership by requesting a token over plain HTTP on this port. Once enabled and the server is restarted, this port stays bound for the life of the process (not just during an obtain) — so you can confirm your router/proxy port-forwarding actually reaches this host by browsing to it directly and expecting a plain "ok" response.</ div >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Staging mode</ label >
< select class = "form-select" name = "staging" >
< option value = "false" {{ if ne . leHTTP . staging " true "}} selected {{ end }} > No — request a real, trusted certificate</ option >
< option value = "true" {{ if eq . leHTTP . staging " true "}} selected {{ end }} > Yes — untrusted test certificate, no rate limits</ option >
</ select >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Contact Email</ label >
< input type = "email" class = "form-control" name = "contact_email" value = "{{.leHTTP.contact_email}}" >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Domains</ label >
< input type = "text" class = "form-control font-monospace" name = "domains" value = "{{.leHTTP.domains}}" placeholder = "mail.example.com" >
< div class = "form-text" > Comma-separated.</ div >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Also get this certificate for the server's IP address</ label >
< select class = "form-select" name = "include_ip" >
< option value = "false" {{ if ne . leHTTP . include_ip " true "}} selected {{ end }} > No — domain only</ option >
< option value = "true" {{ if eq . leHTTP . include_ip " true "}} selected {{ end }} > Yes</ option >
</ select >
< div class = "form-text" > Adds the IP as a second identifier on the same certificate, so clients connecting by bare IP (no hostname) get a trusted cert too. This automatically switches to Let's Encrypt's "shortlived" certificate profile, the only one that currently allows IP identifiers — those certificates are valid for only about 6 days, so expect much more frequent renewals than the domain-only case (handled automatically by the existing renewal check).</ div >
</ div >
< div class = "mb-3" >
< label class = "form-label" > IP address override</ label >
< div class = "input-group" >
< input type = "text" class = "form-control font-monospace" name = "ip_override" id = "le_ip_override" value = "{{.leHTTP.ip_override}}" placeholder = "Leave blank to autodetect this host's WAN IP on every obtain/renew" >
< button class = "btn btn-outline-secondary" type = "button" id = "le_detect_ip_btn" >< i class = "bi bi-broadcast me-1" ></ i > Detect</ button >
2026-08-12 21:14:19 +01:00
</ div >
</ div >
2026-08-15 12:35:44 +01:00
< button type = "submit" class = "btn btn-success" >< i class = "bi bi-check-lg me-1" ></ i > Save HTTP-01 Configuration</ button >
</ form >
2026-08-12 21:14:19 +01:00
</ div >
</ div >
{{end}}
{{define "extra_js"}}
< script >
function updateProviderFields () {
const selected = document . getElementById ( 'le_provider' ). value ;
document . querySelectorAll ( '.provider-fields' ). forEach ( function ( el ) {
el . style . display = ( el . id === 'fields-' + selected ) ? '' : 'none' ;
});
}
document . getElementById ( 'le_provider' ). addEventListener ( 'change' , updateProviderFields );
updateProviderFields ();
2026-08-15 12:35:44 +01:00
document . getElementById ( 'le_detect_ip_btn' ). addEventListener ( 'click' , function () {
fetch ( '/pymta-manager/api/letsencrypt/detect_ip' )
. then ( r => r . json ())
. then ( data => {
if ( data . status === 'success' ) { document . getElementById ( 'le_ip_override' ). value = data . ip ; showToast ( 'Detected WAN IP: ' + data . ip , 'success' ); }
else { showToast ( data . message || 'Failed to detect IP' , 'danger' ); }
}). catch (() => showToast ( 'Failed to detect IP' , 'danger' ));
});
2026-08-12 21:14:19 +01:00
document . getElementById ( 'gcloudKeyUpload' ). addEventListener ( 'change' , function ( e ) {
const file = e . target . files [ 0 ];
if ( ! file ) return ;
const formData = new FormData ();
formData . append ( 'gcloud_key_file' , file );
fetch ( '/pymta-manager/api/letsencrypt/upload_gcloud_key' , { method : 'POST' , body : formData })
. then ( r => r . json ())
. then ( data => {
if ( data . status === 'success' ) { document . getElementById ( 'gcloud_sa_path' ). value = data . filepath ; showToast ( 'Service account key uploaded' , 'success' ); }
else { showToast ( data . message || 'Failed to upload key' , 'danger' ); }
}). catch (() => showToast ( 'Failed to upload key' , 'danger' ));
});
</ script >
{{end}}