2026-08-12 21:14:19 +01:00
{{define "webmail_account.html"}}
<!DOCTYPE html>
< html lang = "en" data-bs-theme = "dark" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > {{.mailbox.Email}} - Webmail</ title >
2026-08-14 13:04:55 +01:00
< 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" >
2026-08-12 21:14:19 +01:00
< style >
body { background-color : #1a1a1a ; color : #e0e0e0 ; }
. card { background-color : #2d2d2d ; border : 1 px solid #404040 ; }
. table-dark { --bs-table-bg : #2d2d2d ; --bs-table-border-color : #404040 ; }
</ style >
</ head >
< body >
2026-08-14 13:04:55 +01:00
{{template "csrf_script" .}}
2026-08-12 21:14:19 +01:00
< 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 >
2026-08-14 13:04:55 +01:00
< 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 >
< 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 >
</ div >
2026-08-12 21:14:19 +01:00
</ div >
</ nav >
< div class = "toast-container position-fixed top-0 end-0 p-3" style = "z-index: 1090;" >
{{range .flashes}}
< div class = "toast align-items-center text-bg-{{if eq .Category " error "}} danger {{ else }}{{. Category }}{{ end }} border-0 " role = "alert" aria-live = "assertive" aria-atomic = "true" data-bs-autohide = "false" >
< div class = "d-flex" >
< div class = "toast-body" >
< i class = "bi bi-{{if eq .Category " error "}} exclamation-triangle {{ else if eq . Category " success "}} check-circle {{ else }} info-circle {{ end }} me-2 " ></ i >
{{.Message}}
</ div >
< button type = "button" class = "btn-close btn-close-white me-2 m-auto" data-bs-dismiss = "toast" aria-label = "Close" ></ button >
</ div >
</ div >
{{end}}
</ div >
< div class = "container pb-5" >
< div class = "row" >
< div class = "col-lg-6 mb-4" >
< div class = "card" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-hdd me-2" ></ i > Storage</ h5 ></ div >
< div class = "card-body" >
< div class = "progress mb-2" style = "height: 1.25rem;" >
< div class = "progress-bar {{if ge .pct_full 90.0}}bg-danger{{else if ge .pct_full 75.0}}bg-warning{{else}}bg-success{{end}}" style = "width: {{printf " %. 0f " . pct_full }}%" > {{printf "%.0f" .pct_full}}%</ div >
</ div >
< small class = "text-muted" > {{filesize .mailbox.UsedBytes}} of {{filesize .mailbox.QuotaBytes}} used</ small >
</ 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" >
< form method = "POST" action = "/webmail/account/password" >
< div class = "mb-3" >
< label class = "form-label" > Current Password</ label >
< input type = "password" class = "form-control" name = "current_password" required >
</ div >
< div class = "mb-3" >
< label class = "form-label" > New Password</ label >
< input type = "password" class = "form-control" name = "new_password" required minlength = "10" >
< div class = "form-text" > At least 10 characters, with a letter, a number, and a symbol.</ div >
</ div >
< div class = "mb-3" >
< label class = "form-label" > Confirm New Password</ label >
< input type = "password" class = "form-control" name = "new_password_confirm" required >
</ div >
< button type = "submit" class = "btn btn-primary" >< i class = "bi bi-check-lg me-1" ></ i > Update Password</ button >
</ form >
</ div >
</ div >
< div class = "card mt-4" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-shield-lock me-2" ></ i > Two-Factor Authentication</ h5 ></ div >
< div class = "card-body" >
< h6 > Authenticator App</ h6 >
{{if .mailbox.TOTPEnabled}}
< p class = "text-success" >< i class = "bi bi-check-circle me-1" ></ i > Enabled</ p >
< form method = "post" action = "/webmail/account/totp/disable" >
< button type = "submit" class = "btn btn-outline-danger btn-sm" data-confirm = "Disable authenticator app MFA?" > Disable</ button >
</ form >
{{else}}
< p class = "text-muted" > Not enabled.</ p >
< form method = "post" action = "/webmail/account/totp/setup" >
< button type = "submit" class = "btn btn-outline-primary btn-sm" >< i class = "bi bi-qr-code me-1" ></ i > Set Up</ button >
</ form >
{{end}}
< hr >
< h6 > Passkeys</ h6 >
{{if .passkeys}}
< ul class = "list-group list-group-flush mb-3" >
{{range .passkeys}}
< li class = "list-group-item list-group-item-dark d-flex justify-content-between align-items-center" >
{{.Name}}
< form method = "post" action = "/webmail/account/passkey/{{.ID}}/remove" class = "d-inline" >
< button type = "submit" class = "btn btn-outline-danger btn-sm" data-confirm = "Remove passkey "{{.Name}}"?" >< i class = "bi bi-trash" ></ i ></ button >
</ form >
</ li >
{{end}}
</ ul >
{{else}}
< p class = "text-muted" > No passkeys registered.</ p >
{{end}}
< button type = "button" class = "btn btn-outline-primary btn-sm" id = "passkey-add-btn" >< i class = "bi bi-fingerprint me-1" ></ i > Add a Passkey</ button >
< div id = "passkey-error" class = "alert alert-danger d-none mt-2" ></ div >
</ div >
</ div >
</ div >
< div class = "col-lg-6 mb-4" >
< div class = "card" >
< div class = "card-header" >< h5 class = "mb-0" >< i class = "bi bi-key me-2" ></ i > App Passwords</ h5 ></ div >
< div class = "card-body" >
< div class = "alert alert-info" >
< i class = "bi bi-info-circle me-2" ></ i > Use an app password (never your account password) to set up this mailbox in Thunderbird or any other mail client.
</ div >
< form method = "POST" action = "/webmail/account/apppasswords/add" class = "row g-2 align-items-end mb-3" >
< div class = "col-auto" >
< label class = "form-label" > Label</ label >
< input type = "text" class = "form-control" name = "label" placeholder = "e.g. Thunderbird laptop" >
</ div >
< div class = "col-auto" >
< button type = "submit" class = "btn btn-success" >< i class = "bi bi-key me-2" ></ i > Generate</ button >
</ div >
</ form >
{{if .passwords}}
< div class = "table-responsive" >
< table class = "table table-dark table-hover mb-0" >
< thead >< tr >< th > Label</ th >< th > Last Used</ th >< th ></ th ></ tr ></ thead >
< tbody >
{{range .passwords}}
< tr >
< td > {{.Label}}</ td >
< td >< small class = "text-muted" > {{if .LastUsedAt}}{{strftime "%Y-%m-%d %H:%M" .LastUsedAt}}{{else}}Never{{end}}</ small ></ td >
< td >
< form method = "post" action = "/webmail/account/apppasswords/{{.ID}}/revoke" class = "d-inline" >
< button type = "submit" class = "btn btn-outline-danger btn-sm" data-confirm = "Revoke app password "{{.Label}}"?" >< i class = "bi bi-trash" ></ i ></ button >
</ form >
</ td >
</ tr >
{{end}}
</ tbody >
</ table >
</ div >
{{else}}
< p class = "text-muted mb-0" > No app passwords yet.</ p >
{{end}}
</ div >
</ div >
</ div >
</ div >
</ div >
< div class = "modal fade" id = "confirmationModal" tabindex = "-1" aria-hidden = "true" >
< div class = "modal-dialog" >
< div class = "modal-content" >
< div class = "modal-header" >
< h5 class = "modal-title" >< i class = "bi bi-question-circle me-2" ></ i > Confirm Action</ h5 >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" aria-label = "Close" ></ button >
</ div >
< div class = "modal-body" id = "confirmationModalBody" > Are you sure you want to proceed?</ div >
< div class = "modal-footer" >
< button type = "button" class = "btn btn-secondary" data-bs-dismiss = "modal" > Cancel</ button >
< button type = "button" class = "btn btn-danger" id = "confirmationModalConfirm" > Confirm</ button >
</ div >
</ div >
</ div >
</ div >
2026-08-14 13:04:55 +01:00
{{template "compose_widget" .}}
< script src = "/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js" ></ script >
2026-08-12 21:14:19 +01:00
< script >
const TOAST_AUTOHIDE_MS = 5000 ;
function armToastAutoDismiss ( toastEl , bsToast ) {
let timer = null ;
const start = () => { timer = setTimeout (() => bsToast . hide (), TOAST_AUTOHIDE_MS ); };
const stop = () => { if ( timer ) { clearTimeout ( timer ); timer = null ; } };
toastEl . addEventListener ( 'mouseenter' , stop );
toastEl . addEventListener ( 'mouseleave' , start );
start ();
}
document . addEventListener ( 'DOMContentLoaded' , function () {
document . querySelectorAll ( '.toast' ). forEach ( function ( el ) {
const toast = new bootstrap . Toast ( el );
toast . show ();
armToastAutoDismiss ( el , toast );
});
});
function showConfirmation ( message ) {
return new Promise (( resolve ) => {
const modal = document . getElementById ( 'confirmationModal' );
document . getElementById ( 'confirmationModalBody' ). textContent = message ;
const confirmButton = document . getElementById ( 'confirmationModalConfirm' );
const handleConfirm = () => { resolve ( true ); bootstrap . Modal . getInstance ( modal ). hide (); cleanup (); };
const handleCancel = () => { resolve ( false ); cleanup (); };
const cleanup = () => {
confirmButton . removeEventListener ( 'click' , handleConfirm );
modal . removeEventListener ( 'hidden.bs.modal' , handleCancel );
};
confirmButton . addEventListener ( 'click' , handleConfirm );
modal . addEventListener ( 'hidden.bs.modal' , handleCancel , { once : true });
new bootstrap . Modal ( modal ). show ();
});
}
document . addEventListener ( 'DOMContentLoaded' , function () {
document . querySelectorAll ( '[data-confirm]' ). forEach ( function ( button ) {
button . addEventListener ( 'click' , async function ( e ) {
e . preventDefault ();
if ( await showConfirmation ( this . getAttribute ( 'data-confirm' ))) {
const form = this . closest ( 'form' );
if ( form ) form . submit ();
}
});
});
});
function b64urlToBuf ( s ) {
s = s . replace ( /-/g , '+' ). replace ( /_/g , '/' );
while ( s . length % 4 ) s += '=' ;
const bin = atob ( s );
const buf = new Uint8Array ( bin . length );
for ( let i = 0 ; i < bin . length ; i ++ ) buf [ i ] = bin . charCodeAt ( i );
return buf . buffer ;
}
function bufToB64url ( buf ) {
const bytes = new Uint8Array ( buf );
let bin = '' ;
bytes . forEach ( b => bin += String . fromCharCode ( b ));
return btoa ( bin ). replace ( /\+/g , '-' ). replace ( /\//g , '_' ). replace ( /=+$/ , '' );
}
const passkeyAddBtn = document . getElementById ( 'passkey-add-btn' );
if ( passkeyAddBtn ) {
passkeyAddBtn . addEventListener ( 'click' , async function () {
const errEl = document . getElementById ( 'passkey-error' );
errEl . classList . add ( 'd-none' );
try {
const beginResp = await fetch ( '/webmail/account/passkey/begin' , { method : 'POST' });
if ( ! beginResp . ok ) throw new Error (( await beginResp . json ()). error || 'Could not start passkey registration' );
const options = await beginResp . json ();
const publicKey = options . publicKey ;
publicKey . challenge = b64urlToBuf ( publicKey . challenge );
publicKey . user . id = b64urlToBuf ( publicKey . user . id );
if ( publicKey . excludeCredentials ) {
publicKey . excludeCredentials = publicKey . excludeCredentials . map ( c => ({ ... c , id : b64urlToBuf ( c . id ) }));
}
const cred = await navigator . credentials . create ({ publicKey });
const body = {
id : cred . id ,
rawId : bufToB64url ( cred . rawId ),
type : cred . type ,
response : {
attestationObject : bufToB64url ( cred . response . attestationObject ),
clientDataJSON : bufToB64url ( cred . response . clientDataJSON ),
},
};
const finishResp = await fetch ( '/webmail/account/passkey/finish' , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ( body ),
});
if ( ! finishResp . ok ) throw new Error (( await finishResp . json ()). error || 'Could not save passkey' );
window . location . reload ();
} catch ( e ) {
errEl . textContent = e . message || 'Passkey registration failed' ;
errEl . classList . remove ( 'd-none' );
}
});
}
</ script >
</ body >
</ html >
{{end}}