updated layout for webmail and added http dns letsencrypt

This commit is contained in:
2026-08-15 12:35:44 +01:00
parent 310700407e
commit f283c90f11
49 changed files with 3359 additions and 431 deletions
+69 -5
View File
@@ -7,15 +7,23 @@ import (
"path/filepath"
"strings"
"time"
"mailgoserver/internal/acmecert"
)
// leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] settings — always
// persisted from the submitted form, same as any other settings.html field.
// leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] (DNS-01) settings —
// always persisted from the submitted form, same as any other settings.html field.
var leAlwaysOverwriteFields = []string{
"enabled", "staging", "contact_email", "domains", "dns_provider",
"route53_region", "route53_hosted_zone_id", "gcloud_project",
}
// leHTTPFields are the [LetsEncryptHTTP] (HTTP-01) settings, all plain — no secrets to
// redact, unlike the DNS-01 providers' API credentials.
var leHTTPFields = []string{
"enabled", "staging", "contact_email", "domains", "include_ip", "ip_override",
}
// leSecretFields hold DNS provider credentials. They're never rendered back into the
// form (always blank) and the save handler only overwrites the stored value when the
// submitted field is non-empty — "leave blank to keep the current value", the same
@@ -25,8 +33,9 @@ var leSecretFields = []string{
"digitalocean_api_token", "gcloud_service_account_json_path",
}
// letsEncryptPage shows the current Let's Encrypt status and configuration form.
// Secret fields are always blank in the rendered form — see leSecretFields.
// letsEncryptPage shows the current Let's Encrypt status and configuration forms for
// both the DNS-01 and HTTP-01 managers. Secret fields are always blank in the rendered
// form — see leSecretFields.
func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) {
sec := a.Cfg.Section("LetsEncrypt")
kv := M{}
@@ -36,7 +45,18 @@ func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) {
for _, k := range leSecretFields {
kv[k] = ""
}
a.render(w, r, "letsencrypt.html", M{"active": "letsencrypt", "le": kv, "status": a.ACME.Status()})
httpSec := a.Cfg.Section("LetsEncryptHTTP")
httpKV := M{}
for _, k := range leHTTPFields {
httpKV[k] = httpSec.Key(k).String()
}
a.render(w, r, "letsencrypt.html", M{
"active": "letsencrypt",
"le": kv, "status": a.ACME.Status(),
"leHTTP": httpKV, "statusHTTP": a.ACMEHTTP.Status(),
})
}
// letsEncryptSave is a dedicated handler (not the generic settingsUpdate reflection)
@@ -79,6 +99,39 @@ func (a *App) letsEncryptObtainNow(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
}
// letsEncryptHTTPSave mirrors letsEncryptSave for the [LetsEncryptHTTP] section — a
// separate handler (not the generic settingsUpdate reflection) purely so it can
// redirect back to /letsencrypt like its DNS-01 sibling; every field here is plain, so
// unlike letsEncryptSave there's no secret-redaction concern.
func (a *App) letsEncryptHTTPSave(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
return
}
sec := a.Cfg.Section("LetsEncryptHTTP")
for _, k := range leHTTPFields {
sec.Key(k).SetValue(r.FormValue(k))
}
if err := a.Cfg.SaveTo(a.ConfigPath); err != nil {
setFlash(w, "error", "Error saving settings: "+err.Error())
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
return
}
setFlash(w, "success", `Let's Encrypt HTTP-01 settings saved. If you just changed "Enable HTTP-01" or the port, restart the server before using "Obtain / Renew Now" — the challenge responder only starts at boot.`)
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
}
// letsEncryptHTTPObtainNow mirrors letsEncryptObtainNow for the HTTP-01 manager.
func (a *App) letsEncryptHTTPObtainNow(w http.ResponseWriter, r *http.Request) {
if err := a.ACMEHTTP.ObtainOrRenew(r.Context()); err != nil {
setFlash(w, "error", "Could not obtain certificate: "+err.Error())
} else {
setFlash(w, "success", "Certificate obtained successfully")
}
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
}
// uploadGCloudServiceAccount mirrors settings.go's uploadTLSFile two-step flow: upload
// the file, return its saved path as JSON, and the browser fills a sibling text input
// with that path — the path only actually persists once the surrounding form (Save)
@@ -113,3 +166,14 @@ func (a *App) uploadGCloudServiceAccount(w http.ResponseWriter, r *http.Request)
}
writeJSON(w, http.StatusOK, M{"status": "success", "filepath": filePath})
}
// detectWANIP backs the "Detect" button next to the HTTP-01 manual IP override field —
// a live lookup, not persisted anywhere until the surrounding form is saved.
func (a *App) detectWANIP(w http.ResponseWriter, r *http.Request) {
ip, err := acmecert.DetectWANIP(r.Context())
if err != nil {
writeJSON(w, http.StatusBadGateway, M{"status": "error", "message": err.Error()})
return
}
writeJSON(w, http.StatusOK, M{"status": "success", "ip": ip})
}
+30 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"html/template"
"net/http"
"net/mail"
"strconv"
"strings"
"time"
@@ -50,6 +51,26 @@ func (a *App) funcMap() template.FuncMap {
"lower": strings.ToLower,
"safe": func(s string) template.HTML { return template.HTML(s) },
"filesize": humanFileSize,
// senderName shows just the display name from a "Name <addr@example.com>"
// cached_from value (the message's own From: header, cached verbatim — see
// smtpserver's fromHeader) — falls back to the bare address when there's no
// display name, or the value doesn't parse as one (e.g. an older row cached
// before this, or a plain envelope address with no header form).
"senderName": func(s string) string {
if addr, err := mail.ParseAddress(s); err == nil && addr.Name != "" {
return addr.Name
}
return s
},
// initial is the avatar-circle letter for a sender name/address — the first
// rune, uppercased; falls back to "?" for an empty value rather than an empty
// circle.
"initial": func(s string) string {
for _, r := range s {
return strings.ToUpper(string(r))
}
return "?"
},
"dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
@@ -134,7 +155,7 @@ var pages = []string{
"ips.html", "add_ip.html", "edit_ip.html",
"blacklist.html",
"dkim.html", "edit_dkim.html",
"settings.html", "letsencrypt.html", "logs.html", "view_message_content.html", "error.html",
"settings.html", "letsencrypt.html", "logs.html", "error.html",
"account.html", "first_login.html",
"admins.html", "add_admin.html", "edit_admin.html",
}
@@ -148,6 +169,14 @@ var standalonePages = []string{
"login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html",
"webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html",
"webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html",
// A bare HTML fragment (no <html>/base.html chrome at all), fetched via JS and
// injected into logs.html's full-screen modal — not a page anyone navigates to
// directly, so it doesn't need to look like a standalone document the way the
// other entries in this list (all real standalone pages) do.
"view_message_content.html",
// Same idea as view_message_content.html above, but for webmail_folder.html's
// reading pane instead of the admin log's modal.
"webmail_message_pane.html",
}
// pagesWithComposeWidget are the standalone pages that show a Compose/Reply/Forward
+191 -100
View File
@@ -6,21 +6,29 @@
<h2><i class="bi bi-patch-check me-2"></i>Let's Encrypt</h2>
</div>
<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>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-check me-2"></i>Status</h5></div>
<div class="card-header"><h5 class="mb-0"><i class="bi bi-globe me-2"></i>DNS-01</h5></div>
<div class="card-body">
<dl class="row mb-3">
<dt class="col-sm-3">Mode</dt>
<dd class="col-sm-9">
{{if .status.Enabled}}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Let's Encrypt {{if .status.Staging}}(staging){{end}}</span>
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Enabled {{if .status.Staging}}(staging){{end}}</span>
{{else}}
<span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Self-signed (Let's Encrypt disabled)</span>
<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 .status.Domains}}{{range .status.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}</dd>
<dt class="col-sm-3">Provider</dt>
<dt class="col-sm-3">DNS Provider</dt>
<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>
@@ -36,114 +44,188 @@
</dd>
</dl>
<form method="post" action="/pymta-manager/letsencrypt/obtain">
<button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button>
<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>
</form>
</div>
</div>
<form method="POST" action="/pymta-manager/letsencrypt/save">
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h5></div>
<div class="card-header"><h5 class="mb-0"><i class="bi bi-hdd-network me-2"></i>HTTP-01</h5></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Enable Let's Encrypt</label>
<select class="form-select" name="enabled">
<option value="false" {{if ne .le.enabled "true"}}selected{{end}}>No — keep the self-signed certificate</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>
<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>
<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">
<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>
</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 Configuration</button>
<button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save HTTP-01 Configuration</button>
</form>
</div>
</div>
</form>
{{end}}
{{define "extra_js"}}
@@ -157,6 +239,15 @@
document.getElementById('le_provider').addEventListener('change', updateProviderFields);
updateProviderFields();
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'));
});
document.getElementById('gcloudKeyUpload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
+27 -2
View File
@@ -62,7 +62,7 @@
<div class="col-md-6"><strong>Message ID:</strong> <code>{{$log.MessageID}}</code></div>
</div>
{{if $log.Subject}}<div class="mt-2"><strong>Subject:</strong> {{$log.Subject}}</div>{{end}}
<div class="mt-2"><a href="/pymta-manager/msg/content/{{$log.ID}}" class="btn btn-sm btn-primary"><i class="bi bi-envelope-open-text"></i> View Message Details</a></div>
<div class="mt-2"><button type="button" class="btn btn-sm btn-primary" onclick="openMessageModal({{$log.ID}})"><i class="bi bi-envelope-open-text"></i> View Message Details</button></div>
</div>
{{else}}
{{$log := .data}}
@@ -119,7 +119,7 @@
</div>
{{end}}
{{if .Subject}}<div class="mt-2"><strong>Subject:</strong> {{.Subject}}</div>{{end}}
<div class="mt-2"><a href="/pymta-manager/msg/content/{{.ID}}" class="btn btn-outline-info btn-sm"><i class="bi bi-file-earmark-text me-1"></i> View Full Message</a></div>
<div class="mt-2"><button type="button" class="btn btn-outline-info btn-sm" onclick="openMessageModal({{.ID}})"><i class="bi bi-file-earmark-text me-1"></i> View Full Message</button></div>
</div>
{{end}}
{{else}}
@@ -159,10 +159,35 @@
</div>
</div>
</div>
<div class="modal fade" id="messageContentModal" tabindex="-1" aria-labelledby="messageContentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="messageContentModalLabel"><i class="bi bi-envelope-open-text me-2"></i>Full Message</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="messageContentModalBody">
<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
setInterval(function() { if (document.visibilityState === 'visible') { location.reload(); } }, 30000);
function openMessageModal(id) {
const modalEl = document.getElementById('messageContentModal');
const body = document.getElementById('messageContentModalBody');
body.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
bootstrap.Modal.getOrCreateInstance(modalEl).show();
fetch('/pymta-manager/msg/content/' + id)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function(html) { body.innerHTML = html; })
.catch(function() { body.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
}
</script>
{{end}}
+31 -2
View File
@@ -188,14 +188,14 @@
<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="col-md-6"><div class="mb-3"><label class="form-label">Custom 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="col-md-6"><div class="mb-3"><label class="form-label">Custom 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">
@@ -203,6 +203,35 @@
</div>
</div></div>
</div>
<div class="form-text mb-3">The "custom" certificate: self-signed on first run, or your own uploaded cert/key above.</div>
<hr>
<p class="mb-2">Which certificate each TLS listener uses — <code>custom</code> (above), or one of the two Let's
Encrypt certificates managed on the <a href="/pymta-manager/letsencrypt">Let's Encrypt</a> page. Independent
per listener, e.g. an HTTP-01 cert for mail while the dashboard keeps a DNS-01 or custom cert.</p>
<div class="row">
<div class="col-md-4"><div class="mb-3"><label class="form-label">SMTP (direct-TLS, 465)</label>
<select class="form-select" name="TLS.smtp_tls_cert">
<option value="custom" {{if eq .settings.TLS.smtp_tls_cert "custom"}}selected{{end}}>Custom / self-signed</option>
<option value="letsencrypt_dns" {{if eq .settings.TLS.smtp_tls_cert "letsencrypt_dns"}}selected{{end}}>Let's Encrypt (DNS-01)</option>
<option value="letsencrypt_http" {{if eq .settings.TLS.smtp_tls_cert "letsencrypt_http"}}selected{{end}}>Let's Encrypt (HTTP-01)</option>
</select>
</div></div>
<div class="col-md-4"><div class="mb-3"><label class="form-label">IMAP (direct-TLS, 993)</label>
<select class="form-select" name="TLS.imap_tls_cert">
<option value="custom" {{if eq .settings.TLS.imap_tls_cert "custom"}}selected{{end}}>Custom / self-signed</option>
<option value="letsencrypt_dns" {{if eq .settings.TLS.imap_tls_cert "letsencrypt_dns"}}selected{{end}}>Let's Encrypt (DNS-01)</option>
<option value="letsencrypt_http" {{if eq .settings.TLS.imap_tls_cert "letsencrypt_http"}}selected{{end}}>Let's Encrypt (HTTP-01)</option>
</select>
</div></div>
<div class="col-md-4"><div class="mb-3"><label class="form-label">Admin/webmail HTTPS</label>
<select class="form-select" name="TLS.web_https_cert">
<option value="custom" {{if eq .settings.TLS.web_https_cert "custom"}}selected{{end}}>Custom / self-signed</option>
<option value="letsencrypt_dns" {{if eq .settings.TLS.web_https_cert "letsencrypt_dns"}}selected{{end}}>Let's Encrypt (DNS-01)</option>
<option value="letsencrypt_http" {{if eq .settings.TLS.web_https_cert "letsencrypt_http"}}selected{{end}}>Let's Encrypt (HTTP-01)</option>
</select>
</div></div>
</div>
<div class="form-text">Changing which certificate a listener uses needs a restart to take effect. Once assigned, that listener's certificate then hot-reloads automatically on every future obtain/renew, no restart needed for that part.</div>
</div>
</div>
</div>
@@ -1,49 +1,72 @@
{{define "title"}}View Full Message - Email Log{{end}}
{{define "view_message_content.html"}}
<div class="mb-3">
<strong>From:</strong> {{.log.mail_from}}<br>
<strong>To:</strong> {{.log.to_address}}<br>
<strong>CC:</strong> {{if .log.cc_addresses}}{{.log.cc_addresses}}{{else}}None{{end}}<br>
<strong>BCC:</strong> {{if .log.bcc_addresses}}{{.log.bcc_addresses}}{{else}}None{{end}}<br>
<strong>Subject:</strong> {{if .log.subject}}{{.log.subject}}{{else}}N/A{{end}}<br>
<strong>Date:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .log.created_at}}<br>
</div>
{{define "content"}}
<div class="container mt-4">
<h2>Full Message Content</h2>
<div class="mb-3">
<strong>From:</strong> {{.log.mail_from}}<br>
<strong>To:</strong> {{.log.to_address}}<br>
<strong>CC:</strong> {{if .log.cc_addresses}}{{.log.cc_addresses}}{{else}}None{{end}}<br>
<strong>BCC:</strong> {{if .log.bcc_addresses}}{{.log.bcc_addresses}}{{else}}None{{end}}<br>
<strong>Subject:</strong> {{if .log.subject}}{{.log.subject}}{{else}}N/A{{end}}<br>
<strong>Date:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .log.created_at}}<br>
</div>
{{if .log.attachments}}
<div class="card mb-3">
<div class="card-header"><strong>Attachments:</strong></div>
<div class="card-body">
<ul class="list-group">
{{range .log.attachments}}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div><i class="fas fa-paperclip"></i> {{.Filename}} <small class="text-muted">({{filesize .Size}})</small></div>
<div class="btn-group" role="group">
<a href="/pymta-manager/msg/attachment/{{.ID}}/download" class="btn btn-sm btn-outline-primary" target="_blank" title="Open in new tab"><i class="fas fa-external-link-alt"></i> View</a>
<a href="/pymta-manager/msg/attachment/{{.ID}}/download?download=true" class="btn btn-sm btn-outline-secondary" title="Download file"><i class="fas fa-download"></i> Download</a>
<form method="POST" action="/pymta-manager/msg/attachment/{{.ID}}/delete" style="display: inline;">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete attachment" data-confirm="Are you sure you want to delete this attachment?"><i class="fas fa-trash-alt"></i> Delete</button>
</form>
</div>
</li>
{{end}}
</ul>
{{if .log.attachments}}
<div class="card mb-3">
<div class="card-header"><strong>Attachments:</strong></div>
<div class="card-body">
{{range .log.attachments}}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<div><i class="bi bi-paperclip me-1"></i>{{.Filename}} <small class="text-muted">({{.ContentType}}, {{filesize .Size}})</small></div>
<a href="{{.DataURI}}" target="_blank" rel="noopener" download="{{.Filename}}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-download me-1"></i>Download</a>
</div>
{{if .IsImage}}<img src="{{.DataURI}}" alt="{{.Filename}}" class="img-fluid mt-2 border rounded" style="max-height: 400px;">{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
<div class="card">
<div class="card-header"><strong>Message Content:</strong></div>
<div class="card-body"><pre style="white-space: pre-wrap; word-break: break-all;">{{.log.message_body}}</pre></div>
{{if .log.legacy_attachments}}
<div class="card mb-3">
<div class="card-header"><strong>Saved attachment files</strong> <small class="text-muted">(from this sender/IP's "Store Full Message Content" setting)</small></div>
<div class="card-body">
<ul class="list-group">
{{range .log.legacy_attachments}}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div><i class="bi bi-paperclip me-1"></i>{{.Filename}} <small class="text-muted">({{filesize .Size}})</small></div>
<div class="btn-group" role="group">
<a href="/pymta-manager/msg/attachment/{{.ID}}/download" class="btn btn-sm btn-outline-primary" target="_blank" rel="noopener" title="Open in new tab"><i class="bi bi-box-arrow-up-right"></i> View</a>
<a href="/pymta-manager/msg/attachment/{{.ID}}/download?download=true" class="btn btn-sm btn-outline-secondary" title="Download file"><i class="bi bi-download"></i> Download</a>
<form method="POST" action="/pymta-manager/msg/attachment/{{.ID}}/delete" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this attachment?');">
<input type="hidden" name="csrf_token" value="{{$.csrf_token}}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete attachment"><i class="bi bi-trash"></i> Delete</button>
</form>
</div>
</li>
{{end}}
</ul>
</div>
</div>
{{end}}
<div class="card mt-3">
<div class="card-header"><strong>Message Headers:</strong></div>
<div class="card-body"><pre style="white-space: pre-wrap;">{{.log.email_headers}}</pre></div>
<div class="card">
<div class="card-header"><strong>Message Content:</strong></div>
<div class="card-body">
{{if .log.has_content}}
{{if .log.html_body}}
<div class="p-2 border rounded bg-white text-dark">{{.log.html_body}}</div>
{{else if .log.plain_body}}
<pre style="white-space: pre-wrap; word-break: break-all;">{{.log.plain_body}}</pre>
{{else}}
<p class="text-muted mb-0">Message stored, but no readable body could be parsed out of it.</p>
{{end}}
{{else}}
<p class="text-muted mb-0"><i class="bi bi-shield-lock me-1"></i>Not stored, by design — the message content is only kept in this log when the sender/IP has "Store Full Message Content" enabled, or the message was quarantined as spam. Headers below are always kept.</p>
{{end}}
</div>
</div>
<a href="/pymta-manager/logs?type=emails" class="btn btn-secondary mt-3">Back to Logs</a>
<div class="card mt-3">
<div class="card-header"><strong>Message Headers:</strong></div>
<div class="card-body"><pre style="white-space: pre-wrap;">{{.log.email_headers}}</pre></div>
</div>
{{end}}
@@ -57,6 +57,26 @@
</div>
</div>
<div class="card mt-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Preferences</h5></div>
<div class="card-body">
<form method="POST" action="/webmail/account/preferences">
<label class="form-label">Group similar subjects in the message list</label>
<select class="form-select" name="group_messages">
<option value="false" {{if not .mailbox.GroupMessages}}selected{{end}}>No — show every message separately</option>
<option value="true" {{if .mailbox.GroupMessages}}selected{{end}}>Yes — collapse a run of same-subject messages into one expandable row</option>
</select>
<button type="submit" class="btn btn-primary btn-sm mt-2"><i class="bi bi-check-lg me-1"></i>Save</button>
</form>
<hr>
<form method="POST" action="/webmail/account/rebuild-cache">
<label class="form-label">Refresh sender names &amp; previews for existing mail</label>
<div class="form-text mb-2">Mail already in your folders keeps whatever sender name/preview it was stored with — this only changes going forward automatically. Use this once to bring older messages up to date.</div>
<button type="submit" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-repeat me-1"></i>Refresh now</button>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Change Password</h5></div>
<div class="card-body">
+282 -121
View File
@@ -8,34 +8,71 @@
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
html, body { background-color: #1a1a1a; color: #e0e0e0; height: 100%; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
.folder-link.active { background-color: #0d6efd; color: #fff !important; }
.msg-unread { font-weight: 600; }
.msg-row { cursor: grab; }
.msg-row.dragging { opacity: 0.4; }
.folder-link.drop-hover { background-color: #0d6efd; color: #fff !important; outline: 2px dashed #6ea8fe; outline-offset: -2px; }
.folder-unread-badge { font-size: .7rem; }
.msg-row-older { display: none; }
.msg-group-toggle { cursor: pointer; }
/* A search result can span multiple folders, and the bulk-action endpoint is
scoped to one folder path — rather than a bulk action silently no-oping on
every row from a different folder, selection/bulk actions are just not
offered while searching (per-row actions in the reading pane still work). */
.search-mode .msg-check, .search-mode #selectAllCheck, .search-mode .bulk-btn { display: none; }
/* Outlook-style three-pane shell: fixed-width sidebar + fixed-width list +
flexible reading pane, instead of a responsive 12-column grid — this is
deliberately a fixed desktop layout to match the reference, not a
mobile-first one. */
.mail-shell { display: flex; align-items: stretch; height: calc(100vh - 56px); overflow: hidden; }
.mail-sidebar { width: 230px; flex: 0 0 auto; overflow-y: auto; border-right: 1px solid #404040; padding: .75rem; }
.mail-list-pane { width: 380px; flex: 0 0 auto; overflow-y: auto; border-right: 1px solid #404040; display: flex; flex-direction: column; }
.mail-reading-pane { flex: 1 1 auto; overflow-y: auto; padding: 1.5rem; min-width: 0; }
.mail-toolbar { flex: 0 0 auto; padding: .5rem .75rem; border-bottom: 1px solid #404040; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; }
.mail-list-scroll { flex: 1 1 auto; overflow-y: auto; }
.mail-list-header { padding: .35rem .75rem; font-size: .75rem; text-transform: uppercase; color: #8a8a8a; display: flex; justify-content: space-between; }
.msg-item { display: flex; align-items: flex-start; gap: .6rem; padding: .55rem .75rem; border-bottom: 1px solid #333; cursor: pointer; }
.msg-item:hover { background-color: #262626; }
.msg-item.active { background-color: #0d3860; }
.msg-item.unread .msg-subject { font-weight: 700; color: #fff; }
.msg-item.unread .msg-from { font-weight: 700; color: #fff; }
.msg-check { margin-top: .35rem; flex: 0 0 auto; }
.msg-avatar { width: 34px; height: 34px; border-radius: 50%; background: #495057; color: #fff; display: flex; align-items: center; justify-content: center; font-size: .85rem; font-weight: 600; flex: 0 0 auto; }
.msg-item.unread .msg-avatar { background: #0d6efd; }
.msg-main { min-width: 0; flex: 1 1 auto; }
.msg-row1 { display: flex; justify-content: space-between; gap: .5rem; }
.msg-from { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msg-date { flex: 0 0 auto; font-size: .75rem; color: #8a8a8a; }
.msg-row2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; color: #adb5bd; }
.msg-subject { color: #e0e0e0; }
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-x: auto; }
.msg-body-text { white-space: pre-wrap; word-break: break-word; }
#readingPaneBody .pane-toolbar { border-bottom: 1px solid #404040; padding-bottom: .75rem; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark px-3" style="height: 56px;">
<span class="navbar-brand mb-0 h1"><i class="bi bi-envelope-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
<form method="get" action="/webmail/mail/search" class="mx-auto" style="width: 360px;">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-secondary border-0"><i class="bi bi-search"></i></span>
<input type="search" name="q" id="mailSearchInput" class="form-control" placeholder="Search all mail" value="{{.search_query}}">
</div>
</form>
<div class="navbar-nav flex-row gap-2 ms-auto">
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-primary btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm" title="Rules"><i class="bi bi-funnel"></i></a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm" title="Certs"><i class="bi bi-shield-lock"></i></a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm" title="Account"><i class="bi bi-gear"></i></a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm" title="Sign out"><i class="bi bi-box-arrow-right"></i></button>
</form>
</div>
</nav>
@@ -53,117 +90,123 @@
{{end}}
</div>
<div class="container-fluid pb-5">
<div class="row">
<div class="col-lg-2 mb-4">
<div class="card">
<div class="card-body p-2">
<form method="get" action="/webmail/mail/search" class="mb-2">
<div class="input-group input-group-sm">
<input type="search" name="q" id="mailSearchInput" class="form-control" placeholder="Search all mail" value="{{.search_query}}">
<button type="submit" class="btn btn-outline-light"><i class="bi bi-search"></i></button>
</div>
</form>
<div class="list-group list-group-flush">
{{$active := .active_folder}}
{{$unread := .unread_counts}}
{{range .folders}}
<div class="d-flex align-items-center folder-row">
<a href="/webmail/mail/{{.}}" data-folder="{{.}}" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if eq . $active}}active{{end}}">
<span><i class="bi bi-folder2 me-1"></i>{{.}}</span>
{{$n := index $unread .}}
{{if $n}}<span class="badge bg-primary rounded-pill folder-unread-badge">{{$n}}</span>{{end}}
</a>
{{if not (isStandardFolder .)}}
<form method="post" action="/webmail/mail/folders/{{.}}/remove" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Remove folder" data-confirm="Remove folder &quot;{{.}}&quot;? Any mail in it moves to INBOX."><i class="bi bi-x-lg"></i></button>
</form>
{{end}}
</div>
{{end}}
</div>
<hr class="my-2">
<form method="post" action="/webmail/mail/folders/add" class="d-flex gap-1">
<input type="text" class="form-control form-control-sm" name="name" placeholder="New folder" maxlength="60" required>
<button type="submit" class="btn btn-sm btn-outline-primary" title="Create folder"><i class="bi bi-plus-lg"></i></button>
</form>
</div>
<div class="mail-shell">
<div class="mail-sidebar" id="folderSidebarCol">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted small text-uppercase">Folders</span>
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0" id="sidebarCollapseBtn" title="Hide folder list"><i class="bi bi-chevron-bar-left"></i></button>
</div>
<div class="list-group list-group-flush">
{{$active := .active_folder}}
{{$unread := .unread_counts}}
{{$counts := .folder_counts}}
{{range .folders}}
<div class="d-flex align-items-center folder-row">
<a href="/webmail/mail/{{.}}" data-folder="{{.}}" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if eq . $active}}active{{end}}">
<span><i class="bi bi-folder2 me-1"></i>{{.}}</span>
{{$n := index $unread .}}
{{$total := index $counts .}}
{{if $total}}
<span class="badge {{if $n}}bg-primary{{else}}bg-secondary{{end}} rounded-pill folder-unread-badge" title="{{$total}} total{{if $n}}, {{$n}} unread{{end}}">{{$total}}{{if $n}} / <strong>{{$n}}</strong>{{end}}</span>
{{end}}
</a>
{{if not (isStandardFolder .)}}
<form method="post" action="/webmail/mail/folders/{{.}}/remove" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Remove folder" data-confirm="Remove folder &quot;{{.}}&quot;? Any mail in it moves to INBOX."><i class="bi bi-x-lg"></i></button>
</form>
{{end}}
</div>
{{end}}
</div>
<hr class="my-2">
<form method="post" action="/webmail/mail/folders/add" class="d-flex gap-1">
<input type="text" class="form-control form-control-sm" name="name" placeholder="New folder" maxlength="60" required>
<button type="submit" class="btn btn-sm btn-outline-primary" title="Create folder"><i class="bi bi-plus-lg"></i></button>
</form>
</div>
<div class="mail-list-pane{{if .search_query}} search-mode{{end}}" id="messageListCol">
<div class="mail-toolbar">
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 d-none" id="sidebarShowBtn" title="Show folder list"><i class="bi bi-chevron-bar-right"></i></button>
<input type="checkbox" class="form-check-input" id="selectAllCheck" title="Select all">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="delete" disabled title="Delete"><i class="bi bi-trash"></i></button>
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="read" disabled title="Mark as read"><i class="bi bi-envelope-open"></i></button>
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="unread" disabled title="Mark as unread"><i class="bi bi-envelope"></i></button>
</div>
{{if not .search_query}}
<select class="form-select form-select-sm bulk-move-select" id="bulkMoveSelect" disabled style="width: auto;" title="Move selected to&hellip;">
<option value="">Move to&hellip;</option>
{{$folder := .active_folder}}
{{range .folders}}{{if ne . $folder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
</select>
{{end}}
<button type="button" class="btn btn-sm btn-outline-secondary border-0" onclick="location.reload()" title="Refresh"><i class="bi bi-arrow-clockwise"></i></button>
<div class="ms-auto d-flex align-items-center gap-1">
{{if not .search_query}}
<a href="{{.sort_from_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by sender"><i class="bi bi-person{{if eq .sort_by "from"}}-fill{{end}}"></i>{{if eq .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
<a href="{{.sort_date_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by date"><i class="bi bi-calendar3{{if ne .sort_by "from"}}-fill{{end}}"></i>{{if ne .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
<a href="{{.unread_only_href}}" class="btn btn-sm border-0 py-0 {{if .unread_only}}btn-primary{{else}}btn-outline-secondary{{end}}" title="Unread only"><i class="bi bi-envelope-fill"></i></a>
{{end}}
</div>
</div>
<div class="col-lg-10 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
{{if .search_query}}<i class="bi bi-search me-2"></i>Search results for &ldquo;{{.search_query}}&rdquo;
{{else}}<i class="bi bi-folder2-open me-2"></i>{{.active_folder}}{{end}}
</h5>
<small class="text-muted">{{.total}} message{{if ne .total 1}}s{{end}}</small>
</div>
<div class="card-body p-0">
{{if .messages}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
{{if not .search_query}}<th>{{if eq .active_folder "Sent"}}To{{else}}From{{end}}</th>{{else}}<th>From / To</th>{{end}}
<th>Subject</th>
{{if .search_query}}<th>Folder</th>{{end}}
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
{{$folders := .folders}}
{{$showFolderCol := .search_query}}
{{range .messages}}
{{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}}
{{if eq .Folder "Drafts"}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}}
<tr class="{{if .Unread}}msg-unread{{end}} msg-row{{if .Collapsed}} msg-row-older{{end}}" draggable="true" data-uid="{{.ID}}" data-folder="{{.Folder}}">
<td><a class="text-reset text-decoration-none" href="{{$rowHref}}">{{if eq .Folder "Sent"}}{{if .CachedTo}}{{.CachedTo}}{{else}}(no recipient){{end}}{{else}}{{.CachedFrom}}{{end}}</a></td>
<td>
<a class="text-reset text-decoration-none" href="{{$rowHref}}">{{if .CachedSubject}}{{.CachedSubject}}{{else}}<span class="text-muted">(no subject)</span>{{end}}</a>
{{if gt .GroupExtra 0}}<span class="badge bg-secondary msg-group-toggle" data-group-toggle="{{.ID}}">+{{.GroupExtra}} more</span>{{end}}
</td>
{{if $showFolderCol}}<td><small class="text-muted">{{.Folder}}</small></td>{{end}}
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</small></td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<form method="post" action="/webmail/mail/{{.Folder}}/{{.ID}}/move" class="d-inline-flex">
<select name="target_folder" class="form-select form-select-sm" style="width: auto;" onchange="this.form.submit()">
<option value="">Move to&hellip;</option>
{{$rowFolder := .Folder}}
{{range $folders}}{{if ne . $rowFolder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
</select>
</form>
<form method="post" action="/webmail/mail/{{.Folder}}/{{.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if eq .Folder "Trash"}}Delete permanently{{else}}Move to Trash{{end}}" data-confirm="{{if eq .Folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
<div class="mail-list-header">
<span>{{if .search_query}}Search: &ldquo;{{.search_query}}&rdquo;{{else}}{{.active_folder}}{{end}}</span>
<span>{{.total}} message{{if ne .total 1}}s{{end}}</span>
</div>
<div class="mail-list-scroll" id="mailListScroll">
{{if .messages}}
{{range .messages}}
{{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}}
{{$paneHref := printf "/webmail/mail/%s/%d/pane" .Folder .ID}}
{{$isDraft := eq .Folder "Drafts"}}
{{if $isDraft}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}}
{{$displayName := senderName .CachedFrom}}
{{if eq .Folder "Sent"}}{{if .CachedTo}}{{$displayName = senderName .CachedTo}}{{else}}{{$displayName = "(no recipient)"}}{{end}}{{end}}
<div class="msg-item {{if .Unread}}unread{{end}}{{if .Collapsed}} msg-row-older{{end}} msg-row" draggable="true" data-uid="{{.ID}}" data-folder="{{.Folder}}" data-href="{{$rowHref}}" data-pane-href="{{$paneHref}}" data-is-draft="{{$isDraft}}">
<input type="checkbox" class="form-check-input msg-check" value="{{.ID}}" onclick="event.stopPropagation()">
<div class="msg-avatar">{{initial $displayName}}</div>
<div class="msg-main">
<div class="msg-row1">
<span class="msg-from" title="{{if eq .Folder "Sent"}}{{.CachedTo}}{{else}}{{.CachedFrom}}{{end}}">{{$displayName}}</span>
<span class="msg-date">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</span>
</div>
<div class="msg-row2">
<span class="msg-subject">{{if .CachedSubject}}{{.CachedSubject}}{{else}}(no subject){{end}}</span>
{{if gt .GroupExtra 0}}<span class="badge bg-secondary msg-group-toggle" data-group-toggle="{{.ID}}">+{{.GroupExtra}} more</span>{{end}}
{{if .CachedPreview}} &ndash; {{.CachedPreview}}{{end}}
</div>
</div>
{{if or .has_prev .has_next}}
<div class="d-flex justify-content-between p-3">
{{if .has_prev}}<a href="?page={{sub .page 1}}" class="btn btn-outline-secondary btn-sm">&laquo; Newer</a>{{else}}<span></span>{{end}}
{{if .has_next}}<a href="?page={{add .page 1}}" class="btn btn-outline-secondary btn-sm">Older &raquo;</a>{{end}}
</div>
{{end}}
{{else}}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<h5 class="text-muted mt-3">No messages in {{.active_folder}}</h5>
</div>
{{end}}
</div>
{{end}}
{{else}}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<h6 class="text-muted mt-3">No messages</h6>
</div>
{{end}}
</div>
{{if or .has_prev .has_next}}
<div class="d-flex justify-content-between p-2 border-top" style="border-color: #404040 !important;">
{{if .has_prev}}<a href="{{.prev_href}}" class="btn btn-outline-secondary btn-sm">&laquo; Newer</a>{{else}}<span></span>{{end}}
{{if .has_next}}<a href="{{.next_href}}" class="btn btn-outline-secondary btn-sm">Older &raquo;</a>{{end}}
</div>
{{end}}
</div>
<div class="mail-reading-pane" id="readingPaneBody">
<div class="text-center text-muted py-5">
<i class="bi bi-envelope-open" style="font-size: 3rem;"></i>
<p class="mt-3">Select a message to read</p>
</div>
</div>
</div>
<form method="post" id="bulkActionForm" class="d-none">
<input type="hidden" name="action" id="bulkActionField">
<input type="hidden" name="target_folder" id="bulkTargetFolderField">
</form>
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
@@ -217,9 +260,9 @@
});
// Drag a message row onto a folder in the sidebar to move it there — a
// shortcut for the same "Move to..." dropdown every row already has. Each
// row carries its OWN folder (data-folder) rather than assuming the page's
// active folder, since a search result can span multiple folders.
// shortcut for the toolbar's "Move to..." control. Each row carries its OWN
// folder (data-folder) rather than assuming the page's active folder, since a
// search result can span multiple folders.
(function() {
let draggedUID = null;
let draggedFolder = null;
@@ -251,6 +294,7 @@
if (!draggedUID || !targetFolder || targetFolder === draggedFolder) return;
const body = new URLSearchParams();
body.set('target_folder', targetFolder);
body.set('csrf_token', window.__csrfToken || '');
await fetch(`/webmail/mail/${draggedFolder}/${draggedUID}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -268,7 +312,7 @@
badge.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
let sib = badge.closest('tr').nextElementSibling;
let sib = badge.closest('.msg-item').nextElementSibling;
while (sib && sib.classList.contains('msg-row-older')) {
sib.style.display = '';
sib = sib.nextElementSibling;
@@ -276,6 +320,123 @@
badge.style.display = 'none';
});
});
// Folder sidebar collapse — a display preference remembered per-browser
// (localStorage), not server state; default is pinned open (nothing stored
// yet == not collapsed).
(function() {
const KEY = 'webmail_sidebar_collapsed';
const sidebarCol = document.getElementById('folderSidebarCol');
const showBtn = document.getElementById('sidebarShowBtn');
function apply(collapsed) {
sidebarCol.style.display = collapsed ? 'none' : '';
showBtn.classList.toggle('d-none', !collapsed);
}
apply(localStorage.getItem(KEY) === '1');
document.getElementById('sidebarCollapseBtn').addEventListener('click', function() {
localStorage.setItem(KEY, '1');
apply(true);
});
showBtn.addEventListener('click', function() {
localStorage.setItem(KEY, '0');
apply(false);
});
})();
// Reading pane: clicking a row loads the message via fetch instead of
// navigating away, mirroring the admin dashboard's message-log modal. Drafts
// still navigate to compose (there's nothing to "read"). The clicked row is
// marked read optimistically client-side — the pane fetch itself is what
// actually marks it read server-side (see webmailMessagePane).
(function() {
const paneBody = document.getElementById('readingPaneBody');
document.querySelectorAll('.msg-item').forEach(function(row) {
row.addEventListener('click', function(e) {
if (e.target.closest('.msg-group-toggle') || e.target.classList.contains('msg-check')) return;
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
document.querySelectorAll('.msg-item.active').forEach(function(r) { r.classList.remove('active'); });
row.classList.add('active');
row.classList.remove('unread');
paneBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
fetch(row.dataset.paneHref)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function(html) { paneBody.innerHTML = html; })
.catch(function() { paneBody.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
});
});
})();
// Selection (checkboxes + select-all + Shift-click range) driving the bulk
// toolbar buttons — enabled only once something's actually selected.
(function() {
const checks = Array.from(document.querySelectorAll('.msg-check'));
const selectAll = document.getElementById('selectAllCheck');
const bulkBtns = document.querySelectorAll('.bulk-btn');
const moveSelect = document.getElementById('bulkMoveSelect');
let lastCheckedIndex = null;
function updateToolbar() {
const any = checks.some(function(c) { return c.checked; });
bulkBtns.forEach(function(b) { b.disabled = !any; });
if (moveSelect) moveSelect.disabled = !any;
selectAll.checked = checks.length > 0 && checks.every(function(c) { return c.checked; });
}
checks.forEach(function(cb, i) {
cb.addEventListener('click', function(e) {
if (e.shiftKey && lastCheckedIndex !== null) {
const [from, to] = [lastCheckedIndex, i].sort(function(a, b) { return a - b; });
for (let j = from; j <= to; j++) checks[j].checked = cb.checked;
}
lastCheckedIndex = i;
updateToolbar();
});
});
selectAll.addEventListener('change', function() {
checks.forEach(function(c) { c.checked = selectAll.checked; });
updateToolbar();
});
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
selectAll.checked = true;
checks.forEach(function(c) { c.checked = true; });
updateToolbar();
}
});
function selectedUIDs() { return checks.filter(function(c) { return c.checked; }).map(function(c) { return c.value; }); }
function submitBulk(action, targetFolder) {
const uids = selectedUIDs();
if (uids.length === 0) return;
const form = document.getElementById('bulkActionForm');
form.action = '/webmail/mail/{{.active_folder}}/bulk';
document.getElementById('bulkActionField').value = action;
document.getElementById('bulkTargetFolderField').value = targetFolder || '';
form.querySelectorAll('input[name="uid"]').forEach(function(el) { el.remove(); });
uids.forEach(function(uid) {
const input = document.createElement('input');
input.type = 'hidden'; input.name = 'uid'; input.value = uid;
form.appendChild(input);
});
const csrf = document.createElement('input');
csrf.type = 'hidden'; csrf.name = 'csrf_token'; csrf.value = window.__csrfToken || '';
form.appendChild(csrf);
form.submit();
}
document.querySelectorAll('.bulk-btn').forEach(function(btn) {
btn.addEventListener('click', async function() {
const action = btn.dataset.action;
if (action === 'delete' && !(await showConfirmation('Move the selected message(s) to Trash?'))) return;
submitBulk(action);
});
});
if (moveSelect) {
moveSelect.addEventListener('change', function() {
if (moveSelect.value) submitBulk('move', moveSelect.value);
});
}
})();
</script>
</body>
</html>
@@ -0,0 +1,91 @@
{{define "webmail_message_pane.html"}}
<div class="pane-toolbar d-flex justify-content-between align-items-center mb-3">
<div class="btn-group btn-group-sm">
<button type="button" onclick="openCompose('/webmail/mail/compose?reply={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply"><i class="bi bi-reply"></i></button>
<button type="button" onclick="openCompose('/webmail/mail/compose?replyall={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply All"><i class="bi bi-reply-all"></i></button>
<button type="button" onclick="openCompose('/webmail/mail/compose?forward={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Forward"><i class="bi bi-arrow-right"></i></button>
</div>
<div class="d-flex align-items-center gap-2">
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/move" class="d-flex align-items-center gap-1">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
<select name="target_folder" class="form-select form-select-sm" style="width: auto;">
<option value="">Move to&hellip;</option>
{{$folder := .active_folder}}
{{range .folders}}{{if ne . $folder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
</select>
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Move"><i class="bi bi-folder-symlink"></i></button>
</form>
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete" onsubmit="return confirm('{{if eq .active_folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}');">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if eq .active_folder "Trash"}}Delete Permanently{{else}}Move to Trash{{end}}"><i class="bi bi-trash"></i></button>
</form>
</div>
</div>
<h5 class="mb-2">{{if .parsed.Header.Subject}}{{.parsed.Header.Subject}}{{else}}<span class="text-muted">(no subject)</span>{{end}}</h5>
{{if or .smime.Signed .smime.Encrypted}}
<div class="mb-2">
{{if .smime.Encrypted}}
{{if .smime.Decrypted}}<span class="badge bg-success"><i class="bi bi-unlock-fill me-1"></i>Encrypted &amp; decrypted</span>
{{else}}<span class="badge bg-danger" title="{{.smime.DecryptErr}}"><i class="bi bi-lock-fill me-1"></i>Encrypted — could not decrypt</span>{{end}}
{{end}}
{{if .smime.Signed}}
{{if .smime.SignatureOK}}<span class="badge bg-success" title="{{.smime.SignerEmail}}"><i class="bi bi-patch-check-fill me-1"></i>Signature verified{{if .smime.SignerEmail}} ({{.smime.SignerEmail}}){{end}}</span>
{{else}}<span class="badge bg-danger" title="{{.smime.SignatureErr}}"><i class="bi bi-exclamation-triangle-fill me-1"></i>Signature invalid</span>{{end}}
{{end}}
</div>
{{end}}
{{if .pgp.Encrypted}}
<div class="mb-2">
{{if .pgp.Decrypted}}<span class="badge bg-success"><i class="bi bi-unlock-fill me-1"></i>PGP encrypted &amp; decrypted</span>
{{else if .pgp.NeedsUnlock}}<span class="badge bg-warning text-dark"><i class="bi bi-lock-fill me-1"></i>PGP encrypted — enter your passphrase to decrypt</span>
{{else}}<span class="badge bg-danger" title="{{.pgp.DecryptErr}}"><i class="bi bi-lock-fill me-1"></i>PGP encrypted — could not decrypt</span>{{end}}
</div>
{{if .pgp.NeedsUnlock}}
<form method="post" action="/webmail/pgp/unlock" class="row g-2 align-items-end mb-2">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
<input type="hidden" name="next" value="{{.message_url}}">
<div class="col-auto">
<select class="form-select form-select-sm" name="identity_id">
{{range .pgp.Identities}}<option value="{{.ID}}">{{if .Label}}{{.Label}}{{else}}Key{{end}} ({{.Fingerprint}})</option>{{end}}
</select>
</div>
<div class="col-auto">
<input type="password" class="form-control form-control-sm" name="passphrase" placeholder="Passphrase" required>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-warning btn-sm">Unlock &amp; Decrypt</button>
</div>
</form>
{{end}}
{{end}}
<div class="small text-muted mb-3">
<div><strong>From:</strong> {{.parsed.Header.From}}</div>
<div><strong>To:</strong> {{.parsed.Header.To}}</div>
{{if .parsed.Header.Cc}}<div><strong>Cc:</strong> {{.parsed.Header.Cc}}</div>{{end}}
<div><strong>Date:</strong> {{.parsed.Header.Date}}</div>
</div>
{{if .html_body}}
<div class="msg-body-html">{{.html_body}}</div>
{{else if .parsed.TextBody}}
<div class="msg-body-text">{{.parsed.TextBody}}</div>
{{else}}
<p class="text-muted mb-0">(empty message body)</p>
{{end}}
{{if .parsed.Attachments}}
<hr>
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
<div class="list-group">
{{$folder := .active_folder}}
{{$uid := .uid}}
{{range $i, $att := .parsed.Attachments}}
<a href="/webmail/mail/{{$folder}}/{{$uid}}/attachment/{{$i}}" class="list-group-item list-group-item-action bg-transparent text-white d-flex justify-content-between align-items-center">
<span><i class="bi bi-file-earmark me-2"></i>{{$att.Filename}}</span>
<i class="bi bi-download"></i>
</a>
{{end}}
</div>
{{end}}
{{end}}
+58 -4
View File
@@ -1,11 +1,14 @@
package webui
import (
"encoding/base64"
"html/template"
"net/http"
"os"
"strings"
"mailgoserver/internal/db"
"mailgoserver/internal/mailview"
)
// emailLogAccessible checks a scoped admin's domain assignment against the sender
@@ -20,7 +23,25 @@ func (a *App) emailLogAccessible(r *http.Request, mailFrom string) (bool, error)
return isGlobal || names[emailDomain(mailFrom)], nil
}
// viewMessageContent mirrors view_message.py's view_message_content().
// viewedAttachment is one attachment ready for the log viewer: decoded bytes encoded
// as a data: URI so no separate download route/disk read is needed, and a browser can
// render it as an inline image directly for the review case this is really for
// (a quarantined message an admin needs to actually inspect, images and all).
type viewedAttachment struct {
Filename string
ContentType string
Size int64 // int64 to match humanFileSize's signature (the "filesize" template func)
DataURI template.URL
IsImage bool
}
// viewMessageContent mirrors view_message.py's view_message_content(). log.MessageBody
// holds the *entire* raw message when this log's content was eligible to be stored
// (see session.go's storeContent) — re-parsed here via mailview (the same parser
// webmail's own message view uses) so the real HTML body, inline images, and
// attachments all render, not just a plain-text approximation. Falls back to showing
// message_body as plain preformatted text if it doesn't parse as a MIME message (e.g.
// an older log row stored before this — plain-text-only — capture existed).
func (a *App) viewMessageContent(w http.ResponseWriter, r *http.Request) {
log, err := a.DB.GetEmailLogByID(pathID(r))
if err != nil || log == nil {
@@ -31,12 +52,45 @@ func (a *App) viewMessageContent(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
attachments, _ := a.DB.ListAttachmentsForEmail(log.ID)
// The old, file-on-disk attachment mechanism (still opt-in-gated the same way it
// always was) — kept as a fallback list for log rows predating the raw-message
// capture below, where this is the only place attachments exist at all.
legacyAttachments, _ := a.DB.ListAttachmentsForEmail(log.ID)
var htmlBody template.HTML
var plainBody string
var attachments []viewedAttachment
if log.MessageBody != "" {
if parsed, err := mailview.Parse([]byte(log.MessageBody)); err == nil {
if parsed.HTMLBody != "" {
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
}
plainBody = parsed.TextBody
for _, att := range parsed.Attachments {
ct := att.ContentType
if ct == "" {
ct = "application/octet-stream"
}
attachments = append(attachments, viewedAttachment{
Filename: att.Filename, ContentType: ct, Size: int64(len(att.Data)),
DataURI: template.URL("data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(att.Data)),
IsImage: strings.HasPrefix(ct, "image/"),
})
}
} else {
// Doesn't parse as MIME — treat the stored string as plain text as-is
// (the shape a pre-fix log row's message_body was always in).
plainBody = log.MessageBody
}
}
a.render(w, r, "view_message_content.html", M{"active": "logs", "log": M{
"id": log.ID, "mail_from": log.MailFrom, "to_address": log.ToAddress,
"cc_addresses": log.CcAddresses, "bcc_addresses": log.BccAddresses,
"subject": log.Subject, "created_at": log.CreatedAt, "message_body": log.MessageBody,
"email_headers": log.EmailHeaders, "attachments": attachments,
"subject": log.Subject, "created_at": log.CreatedAt,
"html_body": htmlBody, "plain_body": plainBody, "has_content": log.MessageBody != "",
"email_headers": log.EmailHeaders, "attachments": attachments, "legacy_attachments": legacyAttachments,
}})
}
@@ -0,0 +1,87 @@
package webui
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"mailgoserver/internal/db"
)
// 1x1 transparent PNG, base64-encoded — a minimal real image for the attachment part.
const testPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
func buildTestMIMEMessageWithImage() string {
boundary := "testboundary123"
return strings.Join([]string{
"From: attacker@evil.example",
"To: victim@example.com",
"Subject: Free money",
"MIME-Version: 1.0",
"Content-Type: multipart/mixed; boundary=\"" + boundary + "\"",
"",
"--" + boundary,
`Content-Type: text/html; charset="UTF-8"`,
"",
"<p>Click <b>here</b> to claim your prize.</p>",
"",
"--" + boundary,
"Content-Type: image/png",
"Content-Transfer-Encoding: base64",
`Content-Disposition: attachment; filename="lure.png"`,
"",
testPNGBase64,
"",
"--" + boundary + "--",
}, "\r\n")
}
// TestViewMessageContentRendersHTMLAndAttachmentInlineForStoredContent confirms that
// when a log's message_body holds a full raw message (the new default for a
// quarantined/opted-in message — see session.go's storeContent), the "View Full
// Message" page actually renders the real HTML body and offers the attachment inline
// (as a data: URI, no separate file/route needed) — not just a plain-text dump, and
// not silently dropping the image the way the old text-only capture always did.
func TestViewMessageContentRendersHTMLAndAttachmentInlineForStoredContent(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
logID, err := app.DB.InsertEmailLog(db.EmailLog{
MessageID: "test-msg-id", Timestamp: time.Now(), PeerIP: "203.0.113.5",
MailFrom: "attacker@evil.example", ToAddress: "victim@example.com", Subject: "Free money",
EmailHeaders: "From: attacker@evil.example\nSubject: Free money",
MessageBody: buildTestMIMEMessageWithImage(),
Status: "failed",
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, Prefix+"/msg/content/"+strconv.FormatInt(logID, 10), nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Click") || !strings.Contains(body, "<b>here</b>") {
t.Errorf("expected the sanitized HTML body rendered, got:\n%s", body)
}
if !strings.Contains(body, "data:image/png;base64,") {
t.Error("expected the attachment rendered inline as a data: URI")
}
if !strings.Contains(body, "lure.png") {
t.Error("expected the attachment's filename shown")
}
// This is fetched into a modal on the logs page, not navigated to directly — it
// must be a bare fragment, not a full page with the dashboard's own nav/sidebar.
if strings.Contains(body, "<!DOCTYPE") || strings.Contains(body, "Email Server Management") || strings.Contains(body, "sidebar_email") {
t.Errorf("expected a bare fragment with no dashboard chrome, got:\n%s", body)
}
}
+40
View File
@@ -3,6 +3,7 @@ package webui
import (
"bytes"
"encoding/base64"
"fmt"
"html/template"
"image/png"
"net/http"
@@ -37,6 +38,45 @@ func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
})
}
// webmailSetGroupMessages toggles the "group similar subjects" folder-view preference
// (see renderFolderOrSearch) — off by default, per-mailbox, purely a display choice.
func (a *App) webmailSetGroupMessages(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetMailboxGroupMessages(mbox.ID, r.FormValue("group_messages") == "true"); err != nil {
a.Logger.Error("set group_messages for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Could not save preference")
} else {
setFlash(w, "success", "Preference saved")
}
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailRebuildMessageCache re-derives cached_from/cached_to/cached_subject/
// cached_preview for every message already in this mailbox — see
// mailstore.RebuildMessageCache's doc comment for why this exists: those fields are
// only ever computed once, at delivery time, so mail stored before a caching fix (like
// showing a sender's display name instead of the bare address) or addition (like the
// preview snippet) landed keeps showing the old/blank value until something
// retroactively re-derives it.
func (a *App) webmailRebuildMessageCache(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
updated, skipped := a.Mailstore.RebuildMessageCache(mbox.ID)
if len(skipped) > 0 {
a.Logger.Error("rebuild message cache for mailbox %d: %d skipped: %v", mbox.ID, len(skipped), skipped)
}
msg := fmt.Sprintf("Refreshed %d message(s)", updated)
if len(skipped) > 0 {
msg += fmt.Sprintf(" — %d could not be read and were left as-is", len(skipped))
}
setFlash(w, "success", msg)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailMFASetupRequiredPage is the isolated, no-navigation landing page
// requireMailboxAuth sends a mailbox owner to when enforce_mailbox_mfa applies and
// they have no second factor yet — the only page (besides the totp/passkey setup
+74
View File
@@ -0,0 +1,74 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
)
// TestWebmailComposeSendBouncesFailedRecipientToSenderInbox confirms that when one
// recipient in a multi-recipient send fails (here: an over-quota local mailbox, caught
// only at delivery time — RCPT-equivalent resolution succeeds), the sender still gets
// their flash "sent, but..." feedback AND a persistent bounce notification lands in
// their own INBOX, mirroring a real mail provider's delivery-failure notice.
func TestWebmailComposeSendBouncesFailedRecipientToSenderInbox(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "sender@example.com", domainID, "sender-password-1!")
// A second local mailbox with an effectively-zero quota, so StoreMessage always
// fails with ErrQuotaExceeded — a hermetic, deterministic delivery failure with no
// network dependency (unlike a relay-to-external-domain failure would be).
hash, err := db.HashPassword("full-password-1!")
if err != nil {
t.Fatal(err)
}
wrapped, nonce, err := app.Mailstore.WrapDEK(mailstore.GenerateDEK())
if err != nil {
t.Fatal(err)
}
fullID, err := app.DB.CreateMailbox("full@example.com", hash, domainID, 1, wrapped, nonce)
if err != nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"full@example.com"}, "subject": {"Big attachment incoming"}, "body_html": {"body text"},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
fullMsgs, err := app.DB.ListMessagesInFolder(fullID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(fullMsgs) != 0 {
t.Fatalf("expected no message delivered to the over-quota mailbox, got %d", len(fullMsgs))
}
bounces, err := app.DB.ListMessagesInFolder(senderID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(bounces) != 1 {
t.Fatalf("expected 1 bounce message in the sender's own INBOX, got %d", len(bounces))
}
if bounces[0].CachedSubject != "Undelivered Mail Returned to Sender" {
t.Errorf("bounce subject = %q", bounces[0].CachedSubject)
}
}
+12 -1
View File
@@ -56,6 +56,12 @@ func TestWebmailComposeSendLocalDelivery(t *testing.T) {
if len(senderSent) != 1 {
t.Fatalf("expected 1 message in sender's Sent folder, got %d", len(senderSent))
}
if isUnread(senderSent[0].Flags) {
t.Error("expected the Sent copy to be marked read, not unread")
}
if !isUnread(recipientMsgs[0].Flags) {
t.Error("expected the recipient's INBOX copy to still be unread")
}
// Recipient can actually read it via the message view.
recipientCookie := webmailLoginSession(t, app, recipientID)
@@ -70,12 +76,17 @@ func TestWebmailComposeSendLocalDelivery(t *testing.T) {
t.Error("expected the message body in the rendered view")
}
// It's also recorded in the admin email log for visibility.
// It's also recorded in the admin email log for visibility — but never with the
// real body content (privacy default; the Sent folder above already keeps the
// real, encrypted-at-rest copy).
logs, _ := app.DB.ListEmailLogsPage(0, 10)
found := false
for _, l := range logs {
if l.Subject == "Hello there" && l.MailFrom == "sender@example.com" {
found = true
if strings.Contains(l.MessageBody, "This is the message body.") {
t.Errorf("expected the real body not to be logged, got %q", l.MessageBody)
}
}
}
if !found {
+25 -6
View File
@@ -638,8 +638,14 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
results = append(results, a.deliverWebmailComposeLocally(rcpt, localTypes[i], from, subject, signed, messageID))
}
if _, err := a.Mailstore.StoreMessage(mbox.ID, "Sent", []byte(signed), messageID, from, subject); err != nil {
if sentUID, err := a.Mailstore.StoreMessage(mbox.ID, "Sent", []byte(signed), messageID, from, subject); err != nil {
a.Logger.Error("store sent copy for mailbox %d: %v", mbox.ID, err)
} else if err := a.DB.SetMessageFlags(mbox.ID, sentUID, `\Seen`); err != nil {
// Mail you just sent yourself was never "unread" to begin with — StoreMessage
// has no way to set initial flags, so this mirrors deliverWebmailComposeLocally's
// existing store-then-mark-read pattern rather than threading a flags param
// through StoreMessage for what only these two Sent/Drafts call sites need.
a.Logger.Error("mark sent copy %d read for mailbox %d: %v", sentUID, mbox.ID, err)
}
// Sending a draft removes it from Drafts, same as any real mail client.
@@ -649,12 +655,13 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
}
}
loggedBody := plainText
// Privacy default (matches the inbound SMTP path — see session.go's Data()): the
// admin-visible log never gets the body content, webmail-sent mail included. There's
// no per-mailbox "store content" opt-in for outbound webmail sends the way there is
// for inbound senders/IPs, and it would be redundant anyway — the sender's own Sent
// folder already keeps the real, encrypted-at-rest copy of what they sent.
loggedBody := "[content not stored by default — see the sender's Sent folder for the full message]"
if wantEncrypt {
// The whole point of checking "Encrypt" is that nobody but the recipient (and
// the sender's own Sent copy) can read it — logging the plaintext into the
// admin-visible email log would defeat that even though the wire content is
// genuinely encrypted.
loggedBody = "[PGP encrypted — plaintext not logged]"
}
if _, err := a.Relay.LogEmail(a.Cfg, a.requestIP(r), from, strings.Join(toAddrs, ", "), strings.Join(ccAddrs, ", "), strings.Join(bccAddrs, ", "),
@@ -664,6 +671,7 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
allSucceeded := len(results) > 0
var failures []string
var failed []relay.Result
for _, res := range results {
if res.Status != "success" {
allSucceeded = false
@@ -672,12 +680,20 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
reason = res.ServerResponse
}
failures = append(failures, res.Recipient+": "+reason)
failed = append(failed, res)
}
}
if allSucceeded {
setFlash(w, "success", "Message sent")
} else {
setFlash(w, "error", "Sent, but delivery failed — "+strings.Join(failures, "; "))
// There's no separate "sending MTA" here to retry/bounce it the way a real
// inbound SMTP client would — the flash message above only exists for the
// moment right after clicking Send, so a persistent copy lands in the
// sender's own INBOX too, same as a real bounce from any other mail provider.
if err := a.Relay.SendBounce(from, subject, messageID, failed); err != nil {
a.Logger.Error("send bounce to %s: %v", from, err)
}
}
http.Redirect(w, r, MailboxPrefix+"/mail/Sent", http.StatusFound)
}
@@ -745,6 +761,9 @@ func (a *App) webmailComposeSaveDraft(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return
}
if err := a.DB.SetMessageFlags(mbox.ID, newUID, `\Seen`); err != nil {
a.Logger.Error("mark draft %d read for mailbox %d: %v", newUID, mbox.ID, err)
}
// Replace, don't accumulate: re-saving an open draft deletes the previous copy.
if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" {
+200 -19
View File
@@ -1,8 +1,10 @@
package webui
import (
"fmt"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
@@ -96,6 +98,35 @@ type folderRow struct {
Collapsed bool
}
// sortLink builds the href for a clickable "From"/"Date" column header: clicking an
// inactive column sorts by it descending; clicking the already-active column flips
// direction; unreadOnly (and folder/query, via the caller building this against the
// current URL) carries over so toggling sort never drops the unread filter.
func sortLink(col string, unreadOnly bool, activeSortBy, activeSortDir string) string {
v := url.Values{}
dir := "desc"
if activeSortBy == col {
if activeSortDir == "asc" {
dir = "desc"
} else {
dir = "asc"
}
}
if col != "" {
v.Set("sort", col)
}
if dir != "desc" {
v.Set("dir", dir)
}
if unreadOnly {
v.Set("unread", "1")
}
if encoded := v.Encode(); encoded != "" {
return "?" + encoded
}
return "?"
}
func isUnread(flags string) bool {
for _, f := range strings.Fields(flags) {
if f == `\Seen` {
@@ -178,12 +209,19 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
if err != nil {
a.Logger.Error("count unread for mailbox %d: %v", mbox.ID, err)
}
folderCounts, err := a.DB.CountMessagesByFolder(mbox.ID)
if err != nil {
a.Logger.Error("count messages by folder for mailbox %d: %v", mbox.ID, err)
}
page := atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
offset := (page - 1) * webmailPageSize
unreadOnly := r.URL.Query().Get("unread") == "1"
sortBy := r.URL.Query().Get("sort") // "" (id/date, default) or "from"
sortDir := r.URL.Query().Get("dir") // "" (desc, default) or "asc"
var total int
var rows []db.MailboxMessage
@@ -194,11 +232,11 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
}
rows, err = a.DB.SearchMessagesInFolder(mbox.ID, folder, query, offset, webmailPageSize)
} else {
total, err = a.DB.CountMessagesInFolder(mbox.ID, folder)
total, err = a.DB.CountMessagesInFolder(mbox.ID, folder, unreadOnly)
if err != nil {
a.Logger.Error("count messages in %s for mailbox %d: %v", folder, mbox.ID, err)
}
rows, err = a.DB.ListMessagesInFolderPage(mbox.ID, folder, offset, webmailPageSize)
rows, err = a.DB.ListMessagesInFolderPage(mbox.ID, folder, unreadOnly, sortBy, sortDir, offset, webmailPageSize)
}
if err != nil {
setFlash(w, "error", "Error loading messages")
@@ -209,36 +247,71 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
}
// Grouping a cross-folder search's results by subject would mix messages that
// happen to share a subject across unrelated folders — only group a real,
// single-folder, unfiltered listing.
if query == "" && folder != "" {
// single-folder, unfiltered listing. Off by default (mbox.GroupMessages) — a
// per-mailbox display preference, toggled from Account.
if query == "" && folder != "" && mbox.GroupMessages {
messages = groupConsecutiveBySubject(messages)
}
unreadToggleV := url.Values{}
if !unreadOnly {
unreadToggleV.Set("unread", "1")
}
if sortBy != "" {
unreadToggleV.Set("sort", sortBy)
}
if sortDir != "" {
unreadToggleV.Set("dir", sortDir)
}
unreadOnlyHref := "?" + unreadToggleV.Encode()
pageHref := func(n int) string {
v := url.Values{}
v.Set("page", strconv.Itoa(n))
if unreadOnly {
v.Set("unread", "1")
}
if sortBy != "" {
v.Set("sort", sortBy)
}
if sortDir != "" {
v.Set("dir", sortDir)
}
return "?" + v.Encode()
}
a.render(w, r, "webmail_folder.html", M{
"mailbox": mbox, "folders": folders, "active_folder": folder,
"messages": messages, "page": page, "total": total,
"has_next": offset+len(rows) < total, "has_prev": page > 1,
"search_query": query, "unread_counts": unreadCounts,
"flashes": popFlashes(w, r),
"search_query": query, "unread_counts": unreadCounts, "folder_counts": folderCounts,
"unread_only": unreadOnly, "sort_by": sortBy, "sort_dir": sortDir,
"sort_from_href": sortLink("from", unreadOnly, sortBy, sortDir),
"sort_date_href": sortLink("", unreadOnly, sortBy, sortDir),
"unread_only_href": unreadOnlyHref,
"prev_href": pageHref(page - 1),
"next_href": pageHref(page + 1),
"flashes": popFlashes(w, r),
})
}
// webmailMessageView decrypts, parses, and renders one message — and marks it read.
func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
// loadMessageForView decrypts, parses, and marks one message read — the shared core
// behind both webmailMessageView (the full standalone page, for direct links/
// bookmarks) and webmailMessagePane (a bare fragment, AJAX-loaded into the folder
// view's Outlook-style reading pane) so the crypto/parse/mark-read logic exists in
// exactly one place. Redirects and returns ok=false itself on any failure, so callers
// just need to bail out when ok is false.
func (a *App) loadMessageForView(w http.ResponseWriter, r *http.Request, mbox *db.Mailbox, folder string, uid int64) (data M, ok bool) {
msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid)
if !ok {
return
return nil, false
}
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
if err != nil {
a.Logger.Error("fetch message %d for mailbox %d: %v", uid, mbox.ID, err)
setFlash(w, "error", "Error loading message")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
return nil, false
}
unwrapped, smimeStatus, pgpStatus := a.unwrapCrypto(r, mbox.ID, raw)
parsed, err := mailview.Parse(unwrapped)
@@ -246,7 +319,7 @@ func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
a.Logger.Error("parse message %d for mailbox %d: %v", uid, mbox.ID, err)
setFlash(w, "error", "Error reading message")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
return nil, false
}
if isUnread(msgRow.Flags) {
@@ -262,12 +335,42 @@ func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
}
a.render(w, r, "webmail_message.html", M{
return M{
"mailbox": mbox, "folders": folders, "active_folder": folder,
"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
"flashes": popFlashes(w, r),
})
}, true
}
// webmailMessageView renders one message as its own full page — direct links/
// bookmarks still work even though the folder view's reading pane (webmailMessagePane)
// is how it's normally opened now.
func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
data, ok := a.loadMessageForView(w, r, mbox, folder, uid)
if !ok {
return
}
data["flashes"] = popFlashes(w, r)
a.render(w, r, "webmail_message.html", data)
}
// webmailMessagePane is webmailMessageView's bare-fragment twin — AJAX-fetched into
// the folder view's reading pane (see webmail_folder.html) instead of navigating to a
// whole new page, mirroring the admin dashboard's message-log modal (view_message.go).
func (a *App) webmailMessagePane(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
data, ok := a.loadMessageForView(w, r, mbox, folder, uid)
if !ok {
return
}
a.render(w, r, "webmail_message_pane.html", data)
}
// webmailMessageWithAccess loads a message and 404s if it doesn't exist, isn't in
@@ -275,9 +378,21 @@ func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
// *WithAccess helpers (mailboxWithAccess etc.): never trust the URL's folder segment
// as authorization, always re-check server-side.
func (a *App) webmailMessageWithAccess(w http.ResponseWriter, r *http.Request, mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
msg, ok := a.messageAccessible(mailboxID, folder, uid)
if !ok {
http.NotFound(w, r)
return nil, false
}
return msg, true
}
// messageAccessible is webmailMessageWithAccess without the side effect of writing a
// 404 response — for webmailBulkAction, where one stale/mismatched uid among a batch
// selected from the page's own checkboxes should just be skipped, not abort (and
// double-write a response for) the whole request.
func (a *App) messageAccessible(mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
msg, err := a.DB.GetMessageByUID(mailboxID, uid)
if err != nil || msg == nil || msg.Folder != folder {
http.NotFound(w, r)
return nil, false
}
return msg, true
@@ -334,6 +449,72 @@ func (a *App) webmailMessageMove(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}
// webmailBulkAction applies one action (delete/move/read/unread) to every uid selected
// via the folder view's checkboxes — the Outlook-style toolbar's bulk equivalent of
// webmailMessageDelete/webmailMessageMove/the auto-mark-read-on-open behavior, all
// through one endpoint rather than four near-identical ones. Every uid is
// independently re-checked against this mailbox+folder (webmailMessageWithAccess) —
// the folder path segment is never trusted as authorization by itself, same as the
// single-message actions.
func (a *App) webmailBulkAction(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
}
action := r.FormValue("action")
target := strings.TrimSpace(r.FormValue("target_folder"))
if action == "move" && target == "" {
setFlash(w, "error", "Choose a folder to move to")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
}
n := 0
for _, uidStr := range r.Form["uid"] {
uid := int64(atoi(uidStr))
if uid == 0 {
continue
}
if _, ok := a.messageAccessible(mbox.ID, folder, uid); !ok {
// A mismatched/stale uid here just means stale client state (the page's
// own checkboxes) — skip it, don't hard-fail the whole batch over one bad
// entry the way the single-message actions correctly do for a URL-level uid.
continue
}
var err error
switch action {
case "delete":
if folder == "Trash" {
err = a.Mailstore.DeleteMessage(mbox.ID, uid)
} else {
err = a.DB.MoveMessage(mbox.ID, uid, "Trash")
}
case "move":
err = a.DB.MoveMessage(mbox.ID, uid, target)
case "read":
err = a.DB.SetMessageFlags(mbox.ID, uid, `\Seen`)
case "unread":
err = a.DB.SetMessageFlags(mbox.ID, uid, "")
default:
continue
}
if err != nil {
a.Logger.Error("bulk %s on message %d for mailbox %d: %v", action, uid, mbox.ID, err)
continue
}
n++
}
if n > 0 {
setFlash(w, "success", fmt.Sprintf("%d message(s) updated", n))
} else {
setFlash(w, "error", "No messages were selected")
}
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}
// webmailAttachmentDownload re-decrypts and re-parses the whole message on every
// download — there's no separate on-disk attachment cache, and message sizes on a
// self-hosted mail server are small enough that this is simpler than building one.
+153
View File
@@ -0,0 +1,153 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// TestWebmailMessagePaneMarksReadAndReturnsFragment confirms the Outlook-style reading
// pane's fetch endpoint returns a bare fragment (no dashboard chrome) containing the
// message body, and marks the message read just like opening the full page does.
func TestWebmailMessagePaneMarksReadAndReturnsFragment(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "panetest@example.com", domains[0].ID, "panetest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
uid := storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Pane test", "the body of the message")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10)+"/pane", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "the body of the message") {
t.Errorf("expected the message body in the fragment, got:\n%s", body)
}
if strings.Contains(body, "<!DOCTYPE") || strings.Contains(body, "navbar-brand") {
t.Errorf("expected a bare fragment with no page chrome, got:\n%s", body)
}
msg, err := app.DB.GetMessageByUID(mailboxID, uid)
if err != nil {
t.Fatal(err)
}
if isUnread(msg.Flags) {
t.Error("expected the message marked read after loading it in the pane")
}
}
// TestWebmailBulkActionDeleteAndMarkRead confirms the folder view's bulk toolbar
// (multi-select checkboxes -> POST .../bulk) can delete-to-Trash and mark-read/unread
// several messages in one request.
func TestWebmailBulkActionDeleteAndMarkRead(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "bulktest@example.com", domains[0].ID, "bulktest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
uid1 := storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "One", "body1")
uid2 := storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Two", "body2")
uid3 := storeTestMessage(t, app, mailboxID, "INBOX", "c@example.com", "Three", "body3")
post := func(action string, uids ...int64) *httptest.ResponseRecorder {
form := url.Values{"action": {action}}
for _, u := range uids {
form.Add("uid", strconv.FormatInt(u, 10))
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/INBOX/bulk", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
// Mark uid1 and uid2 read in one bulk request.
if rec := post("read", uid1, uid2); rec.Code != http.StatusFound {
t.Fatalf("bulk read: status=%d body=%s", rec.Code, rec.Body.String())
}
for _, uid := range []int64{uid1, uid2} {
msg, err := app.DB.GetMessageByUID(mailboxID, uid)
if err != nil {
t.Fatal(err)
}
if isUnread(msg.Flags) {
t.Errorf("expected message %d marked read", uid)
}
}
msg3, err := app.DB.GetMessageByUID(mailboxID, uid3)
if err != nil {
t.Fatal(err)
}
if !isUnread(msg3.Flags) {
t.Error("expected message 3 (not in the bulk request) to remain unread")
}
// Bulk-delete uid1 and uid3 (uid2 stays in INBOX).
if rec := post("delete", uid1, uid3); rec.Code != http.StatusFound {
t.Fatalf("bulk delete: status=%d body=%s", rec.Code, rec.Body.String())
}
inbox, err := app.DB.ListMessagesInFolder(mailboxID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(inbox) != 1 || inbox[0].ID != uid2 {
t.Fatalf("expected only message 2 left in INBOX, got %+v", inbox)
}
trash, err := app.DB.ListMessagesInFolder(mailboxID, "Trash")
if err != nil {
t.Fatal(err)
}
if len(trash) != 2 {
t.Fatalf("expected 2 messages in Trash, got %d", len(trash))
}
}
// TestWebmailBulkActionSkipsUIDFromAnotherFolder confirms a uid that doesn't actually
// belong to the requested folder is silently skipped rather than aborting the whole
// batch or letting a stale/mismatched selection touch the wrong message.
func TestWebmailBulkActionSkipsUIDFromAnotherFolder(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "bulkskiptest@example.com", domains[0].ID, "bulkskiptest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
inboxUID := storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "In inbox", "body")
sentUID := storeTestMessage(t, app, mailboxID, "Sent", "b@example.com", "In sent", "body")
form := url.Values{"action": {"delete"}, "uid": {strconv.FormatInt(inboxUID, 10), strconv.FormatInt(sentUID, 10)}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/INBOX/bulk", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
sent, err := app.DB.ListMessagesInFolder(mailboxID, "Sent")
if err != nil {
t.Fatal(err)
}
if len(sent) != 1 {
t.Fatalf("expected the Sent message untouched (wrong folder for this bulk request), got %d left", len(sent))
}
trash, err := app.DB.ListMessagesInFolder(mailboxID, "Trash")
if err != nil {
t.Fatal(err)
}
if len(trash) != 1 {
t.Fatalf("expected the INBOX message moved to Trash, got %d in Trash", len(trash))
}
}
+216 -4
View File
@@ -80,16 +80,17 @@ func TestWebmailFolderUnreadBadges(t *testing.T) {
return rec.Body.String()
}
if !strings.Contains(get(), `folder-unread-badge">1<`) {
t.Fatalf("expected an unread badge showing 1, got: %s", get())
if !strings.Contains(get(), `folder-unread-badge" title="1 total, 1 unread">1 / <strong>1</strong><`) {
t.Fatalf("expected a badge showing 1 total / 1 unread, got: %s", get())
}
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil)
viewReq.AddCookie(cookie)
mux.ServeHTTP(httptest.NewRecorder(), viewReq)
if strings.Contains(get(), `folder-unread-badge">1<`) {
t.Fatal("expected the unread badge gone after reading the message")
// Still 1 total message, but no longer unread — the "/ N unread" part should be gone.
if !strings.Contains(get(), `folder-unread-badge" title="1 total">1<`) {
t.Fatalf("expected the badge to show just the total (1) with no unread suffix, got: %s", get())
}
}
@@ -101,6 +102,9 @@ func TestWebmailFolderGroupsConsecutiveSameSubject(t *testing.T) {
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "grouper@example.com", domains[0].ID, "grouper-password-1!")
if err := app.DB.SetMailboxGroupMessages(mailboxID, true); err != nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Project status", "1")
@@ -119,3 +123,211 @@ func TestWebmailFolderGroupsConsecutiveSameSubject(t *testing.T) {
t.Fatal("expected the older grouped row hidden by default via msg-row-older")
}
}
// TestWebmailFolderGroupingOffByDefault confirms grouping is off unless a mailbox
// owner explicitly enables it via Account > Preferences — same three messages as
// TestWebmailFolderGroupsConsecutiveSameSubject, but no toggle call this time.
func TestWebmailFolderGroupingOffByDefault(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "nogroup@example.com", domains[0].ID, "nogroup-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Project status", "1")
storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Re: Project status", "2")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
if strings.Contains(body, "+1 more") {
t.Fatal("expected no grouping by default")
}
// Enabling it via the Account > Preferences form flips the behavior live.
prefReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/preferences", strings.NewReader("group_messages=true"))
prefReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
prefReq.AddCookie(cookie)
prefRec := httptest.NewRecorder()
mux.ServeHTTP(prefRec, prefReq)
if prefRec.Code != http.StatusFound {
t.Fatalf("preferences save: status=%d body=%s", prefRec.Code, prefRec.Body.String())
}
req2 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req2.AddCookie(cookie)
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
if !strings.Contains(rec2.Body.String(), "+1 more") {
t.Fatal("expected grouping enabled after saving the preference")
}
}
// TestWebmailFolderShowsSenderDisplayName confirms the folder list shows just the
// display name from a "Name <addr>" cached_from value, not the raw address string,
// while keeping the full address available via the row's title attribute.
func TestWebmailFolderShowsSenderDisplayName(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "namedisplay@example.com", domains[0].ID, "namedisplay-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "Bob Marley <bob@example.com>", "One love", "body")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
if !strings.Contains(body, `title="Bob Marley &lt;bob@example.com&gt;"`) {
t.Errorf("expected the full address in the title attribute, got:\n%s", body)
}
if !strings.Contains(body, ">Bob Marley<") {
t.Errorf("expected just the display name shown in the row, got:\n%s", body)
}
}
// TestWebmailFolderUnreadOnlyFilter confirms ?unread=1 hides read messages.
func TestWebmailFolderUnreadOnlyFilter(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "unreadfilter@example.com", domains[0].ID, "unreadfilter-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Unread one", "body")
readUID := storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Already read", "body")
if err := app.DB.SetMessageFlags(mailboxID, readUID, `\Seen`); err != nil {
t.Fatal(err)
}
get := func(path string) string {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec.Body.String()
}
all := get(MailboxPrefix + "/mail/INBOX")
if !strings.Contains(all, "Unread one") || !strings.Contains(all, "Already read") {
t.Fatalf("expected both messages without the filter, got:\n%s", all)
}
unreadOnly := get(MailboxPrefix + "/mail/INBOX?unread=1")
if !strings.Contains(unreadOnly, "Unread one") {
t.Error("expected the unread message still shown")
}
if strings.Contains(unreadOnly, "Already read") {
t.Errorf("expected the read message hidden with ?unread=1, got:\n%s", unreadOnly)
}
}
// TestWebmailFolderSortByFrom confirms ?sort=from&dir=asc orders the list by sender
// instead of the default received-order.
func TestWebmailFolderSortByFrom(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "sortfrom@example.com", domains[0].ID, "sortfrom-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "zzz@example.com", "From Z", "body")
storeTestMessage(t, app, mailboxID, "INBOX", "aaa@example.com", "From A", "body")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX?sort=from&dir=asc", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
idxA := strings.Index(body, "From A")
idxZ := strings.Index(body, "From Z")
if idxA < 0 || idxZ < 0 || idxA > idxZ {
t.Fatalf("expected 'From A' (aaa@) before 'From Z' (zzz@) when sorted by sender ascending, got:\n%s", body)
}
}
// TestWebmailFolderHasCollapsibleSidebarMarkup is a light smoke test for the
// collapsible-sidebar feature's markup/JS anchors — the actual show/hide behavior is
// client-side (localStorage-backed) and not exercisable from a Go test, but a missing
// element ID here would silently break the JS with no visible error.
func TestWebmailFolderHasCollapsibleSidebarMarkup(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "sidebartest@example.com", domains[0].ID, "sidebartest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
for _, id := range []string{`id="folderSidebarCol"`, `id="messageListCol"`, `id="sidebarCollapseBtn"`, `id="sidebarShowBtn"`, "webmail_sidebar_collapsed"} {
if !strings.Contains(body, id) {
t.Errorf("expected %q present in the rendered page", id)
}
}
}
// TestWebmailFolderShowsMessagePreview confirms the folder list shows a short preview
// snippet of the message body under the subject.
func TestWebmailFolderShowsMessagePreview(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "previewtest@example.com", domains[0].ID, "previewtest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Meeting notes", "Here is a summary of what we discussed today in the meeting.")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
if !strings.Contains(body, "msg-row2") || !strings.Contains(body, "Here is a summary") {
t.Errorf("expected the message preview snippet rendered, got:\n%s", body)
}
}
// TestWebmailRebuildMessageCache confirms the Account > Preferences "Refresh now"
// action re-derives an already-stored message's sender display name from its raw
// content, for mail that predates the fix that started caching it.
func TestWebmailRebuildMessageCache(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "rebuildtest@example.com", domains[0].ID, "rebuildtest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
// Simulate a stale row: the raw content has the display name, but cached_from was
// stored as the bare address (what pre-fix code would have passed).
raw := "From: Bob Marley <bob@example.com>\r\nTo: rebuildtest@example.com\r\nSubject: One love\r\n\r\nHello there"
if _, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(raw), "<one@example.com>", "bob@example.com", "One love"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/rebuild-cache", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("rebuild-cache: status=%d body=%s", rec.Code, rec.Body.String())
}
folderReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
folderReq.AddCookie(cookie)
folderRec := httptest.NewRecorder()
mux.ServeHTTP(folderRec, folderReq)
if !strings.Contains(folderRec.Body.String(), ">Bob Marley<") {
t.Errorf("expected the display name shown after rebuild, got:\n%s", folderRec.Body.String())
}
}
+11 -3
View File
@@ -26,7 +26,8 @@ type App struct {
DB *db.DB
DKIM *dkim.Manager
Mailstore *mailstore.Store
ACME *acmecert.Manager
ACME *acmecert.Manager // DNS-01
ACMEHTTP *acmecert.Manager // HTTP-01
Relay *relay.Relay // used by the webmail client's compose/send (see webmail_compose.go)
Cfg *ini.File
ConfigPath string
@@ -59,10 +60,10 @@ type App struct {
// mailstore's master key is loaded in main.go and threaded in rather than resolved
// internally (both are file paths relative to the app's root working directory,
// which this package doesn't otherwise know).
func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) {
func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr, acmeHTTPMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) {
trustedProxies := parseTrustedProxies(cfg.Section("Server").Key("trusted_proxies").MustString(""), logger)
a := &App{
DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp,
DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, ACMEHTTP: acmeHTTPMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp,
pgpKeys: newPGPKeyCache(), trustedProxies: trustedProxies, loginLimiter: newIPRateLimiter(20, time.Minute), appSecret: appSecret,
}
if err := a.loadTemplates(); err != nil {
@@ -139,6 +140,8 @@ func (a *App) Mux() *http.ServeMux {
webmailMux.HandleFunc("GET "+MailboxPrefix+"/account", a.webmailDashboard)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mfa-setup", a.webmailMFASetupRequiredPage)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/preferences", a.webmailSetGroupMessages)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/rebuild-cache", a.webmailRebuildMessageCache)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/confirm", a.webmailTOTPSetupConfirm)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/disable", a.webmailTOTPDisable)
@@ -156,8 +159,10 @@ func (a *App) Mux() *http.ServeMux {
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/recipients", a.webmailRecipientSuggest)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}", a.webmailFolderView)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}", a.webmailMessageView)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/pane", a.webmailMessagePane)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/delete", a.webmailMessageDelete)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/move", a.webmailMessageMove)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/bulk", a.webmailBulkAction)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachment/{idx}", a.webmailAttachmentDownload)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/add", a.webmailAddFolder)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/remove", a.webmailDeleteFolder)
@@ -277,7 +282,10 @@ func (a *App) Mux() *http.ServeMux {
mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.requireGlobalAdmin(a.letsEncryptPage))
mux.HandleFunc("POST "+Prefix+"/letsencrypt/save", a.requireGlobalAdmin(a.letsEncryptSave))
mux.HandleFunc("POST "+Prefix+"/letsencrypt/obtain", a.requireGlobalAdmin(a.letsEncryptObtainNow))
mux.HandleFunc("POST "+Prefix+"/letsencrypt/http/save", a.requireGlobalAdmin(a.letsEncryptHTTPSave))
mux.HandleFunc("POST "+Prefix+"/letsencrypt/http/obtain", a.requireGlobalAdmin(a.letsEncryptHTTPObtainNow))
mux.HandleFunc("POST "+Prefix+"/api/letsencrypt/upload_gcloud_key", a.requireGlobalAdmin(a.uploadGCloudServiceAccount))
mux.HandleFunc("GET "+Prefix+"/api/letsencrypt/detect_ip", a.requireGlobalAdmin(a.detectWANIP))
mux.HandleFunc("GET "+Prefix+"/logs", a.logs)
+7 -2
View File
@@ -117,6 +117,9 @@ func newTestApp(t *testing.T) *App {
tlsSec, _ := cfg.NewSection("TLS")
tlsSec.NewKey("tls_cert_file", "ssl_certs/server.crt")
tlsSec.NewKey("tls_key_file", "ssl_certs/server.key")
tlsSec.NewKey("smtp_tls_cert", "custom")
tlsSec.NewKey("imap_tls_cert", "custom")
tlsSec.NewKey("web_https_cert", "custom")
dkimSec, _ := cfg.NewSection("DKIM")
dkimSec.NewKey("dkim_key_size", "2048")
dkimSec.NewKey("spf_server_ip", "192.168.1.1")
@@ -129,13 +132,15 @@ func newTestApp(t *testing.T) *App {
configPath := filepath.Join(dir, "settings.ini")
cfg.SaveTo(configPath)
acmeMgr := acmecert.New(cfg, filepath.Join(dir, "server.crt"), filepath.Join(dir, "server.key"), filepath.Join(dir, "acme"), nil, toolbox.GetLogger("test"))
acmeMgr := acmecert.New(cfg, "LetsEncrypt", "dns-01", filepath.Join(dir, "server.crt"), filepath.Join(dir, "server.key"), filepath.Join(dir, "acme"), nil, toolbox.GetLogger("test"))
acmeHTTPMgr := acmecert.New(cfg, "LetsEncryptHTTP", "http-01", filepath.Join(dir, "server.crt"), filepath.Join(dir, "server.key"), filepath.Join(dir, "acme"), nil, toolbox.GetLogger("test"))
relayer := relay.New(database, cfg, toolbox.GetLogger("test"))
relayer.Mailstore = mstore
appSecret, err := LoadOrCreateAppSecret(filepath.Join(dir, "app_secret.key"))
if err != nil {
t.Fatalf("LoadOrCreateAppSecret: %v", err)
}
app, err := New(database, dkimMgr, mstore, acmeMgr, relayer, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true }, appSecret)
app, err := New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true }, appSecret)
if err != nil {
t.Fatalf("New: %v", err)
}