From ee240ae227778b5645a19788d1f4ddc86b23100f Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 2 Aug 2026 19:13:53 -0300 Subject: [PATCH] #23: show real data, unify the surfaces and make enrolment scannable The interface was reporting things that were not true. Several screens were mockups wired to nothing, and a reader with the console in Portuguese was served a mixture of two languages. Fabricated data, removed: - The outbound queue was two invented jobs held in component state. Retry and Freeze only edited that local array, so the screen showed the same two rows on every deployment and reported success for actions that never happened. "Freeze" named an operation the backend has never implemented. It now reads /api/v1/admin/queue, which was already serving the real thing, and offers retry and cancel, which exist. - Every dashboard KPI fell back to an invented constant (1420 received, 890 sent, 3 queued, 18.4% disk), so an unreachable API produced a dashboard of plausible traffic for a server that might have delivered nothing. Two of those fields were never returned by the endpoint at all, so the fake disk figure was what everyone always saw. DMARC did the same with 1250/98%/97%. - The domains screen showed thirteen DNS records for example.com, all marked VERIFIED, and posted {domain_id: "default-domain"} to endpoints expecting a real id. DKIM rotation sends "domain", not "domain_id", so its 400 was swallowed and a made-up TXT record displayed as though a key had been issued: rotation had never once worked from this screen. - The TLS panel asserted a Let's Encrypt RSA 4096 certificate and verified DANE from static markup, on a deployment currently serving a self-signed one. It now reads the cert watcher via /api/v1/admin/tls. - The readiness scorecard printed "10 / 10 all checks passed" regardless of what preflight reported, including failures. - A message trace that found nothing invented a five-step delivery - TLS 1.3 accepted, SPF pass, spam score 0.8 - for a message that does not exist. - Both sidebars showed a fixed admin@lambdamail.local with a hardcoded "SUPER_ADMIN (2FA)", claiming a second factor whether or not one existed. - Settings seeded a "[Boletim]" Sieve rule. Saving posts the whole list, so the first edit wrote that invented filter into the account's real script. The reconcile endpoint itself answered {records_verified: 13, status: "VERIFIED"} for any domain id, including one that does not exist, without resolving a name. It now reports what it does, which is record the request. Surfaces: The root was a chooser listing webmail and the console side by side, which asked visitors a question they should not have to answer and advertised cookie names and audiences to anonymous readers. It now redirects into the mail client. Accounts that may administer the server get a link to the console from inside the app - a link to /admin/login, so crossing over still costs the second factor the admin audience requires - and the console gets a plain link back, since the webmail session was never given up to get there. Enrolment: Both enrolment screens printed the base32 secret as text and left the operator to type 32 characters into their phone. The enrol endpoint already returned an otpauth:// URI; it simply was not drawn. Added a shared component that renders it as a QR locally - the seed is already in the page, and sending it to an image service would put it in another request log for nothing. The recovery-code step rendered its own "sign in" button while the form's submit button stayed visible below it: two identical buttons, and no way to copy the codes it insisted you save. It is now terminal, with copy and continue. Language: translate() had no interpolation, so "Undo Send ({seconds}s remaining)" showed the placeholder and a second hardcoded countdown was appended after it. It now substitutes, leaving an unmatched token visible rather than blanking it. The pt-BR bundle had lost its diacritics - "Destinatario", "Ultimo erro", "Acoes" - from the ASCII-source rule being applied to the translations, which are data. Restored, and the three bundles are back in key parity at 270 each, with roughly 120 strings that were hardcoded in the source now keyed. Healthchecks: web had none, despite being the only service users reach directly: a wedged Next process kept its container running and Traefik kept routing to it. Added, using node's own fetch since the image ships no curl. rspamd and clamav gained the timeouts and start_periods whose absence would fail them during startup. migrate stays without one and says why: it is a one-shot job, and a probe would report unhealthy exactly when it has succeeded. Verified: 36 webmail tests and 41 auth tests against a real Postgres, both new admin/me cases confirmed to fail without the route (404). lang-lint clean, production build clean, 0 vulnerabilities, compose validates. Co-Authored-By: Claude Opus 5 --- docker-compose.yaml | 22 + services/auth/src/repository.ts | 8 +- services/auth/src/router.test.ts | 18 + services/auth/src/router.ts | 23 +- services/webmail/messages/en.json | 404 ++++++++---- services/webmail/messages/es.json | 392 ++++++++---- services/webmail/messages/pt-BR.json | 398 ++++++++---- services/webmail/package-lock.json | 7 + services/webmail/package.json | 1 + .../src/app/(admin)/admin/dashboard/page.tsx | 172 ++--- .../src/app/(admin)/admin/dmarc/page.tsx | 12 +- .../src/app/(admin)/admin/domains/page.tsx | 605 ++++++++---------- .../webmail/src/app/(admin)/admin/layout.tsx | 36 +- .../src/app/(admin)/admin/mailboxes/page.tsx | 47 +- .../src/app/(admin)/admin/queue/page.tsx | 239 +++++-- .../src/app/(admin)/admin/security/page.tsx | 204 +++--- .../src/app/(auth)/admin/login/page.tsx | 75 +-- .../src/app/(auth)/user/login/page.tsx | 14 +- .../src/app/(user)/user/compose/page.tsx | 50 +- .../webmail/src/app/(user)/user/layout.tsx | 47 +- .../app/(user)/user/mail/[folder]/page.tsx | 10 +- .../src/app/(user)/user/settings/page.tsx | 111 ++-- services/webmail/src/app/health/route.ts | 17 +- services/webmail/src/app/page.tsx | 84 +-- .../webmail/src/components/TotpEnrolment.tsx | 160 +++++ services/webmail/src/i18n/config.ts | 26 +- services/webmail/src/i18n/provider.tsx | 17 +- .../webmail/src/interface-integrity.test.ts | 174 +++++ services/webmail/src/lib/useAccount.ts | 49 ++ 29 files changed, 2201 insertions(+), 1221 deletions(-) create mode 100644 services/webmail/src/components/TotpEnrolment.tsx create mode 100644 services/webmail/src/interface-integrity.test.ts create mode 100644 services/webmail/src/lib/useAccount.ts diff --git a/docker-compose.yaml b/docker-compose.yaml index c534626..d7dae5a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -54,6 +54,11 @@ services: networks: [backend] # ----------------------------------------------------------- MIGRATE + # The only service with no healthcheck, deliberately: it is a one-shot job + # that applies the migrations and exits. Everything that depends on it waits + # on service_completed_successfully, which is a stronger guarantee than a + # probe - a health check on a container that is meant to stop would report + # unhealthy exactly when it has done its job. migrate: image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/migrations:${IMAGE_TAG:-latest} restart: "no" @@ -96,7 +101,12 @@ services: healthcheck: test: ["CMD-SHELL", "rspamadm control stat >/dev/null 2>&1 || exit 1"] interval: 30s + # Without an explicit timeout Docker allows 30s, which overlaps the next + # probe; and without a start_period the first checks run while the + # worker is still loading maps and mark the container unhealthy. + timeout: 10s retries: 3 + start_period: 60s mem_limit: 768m networks: [backend, egress] # egress: RBL/DNSBL lookups and fuzzy storage @@ -112,6 +122,7 @@ services: healthcheck: test: ["CMD-SHELL", "clamdcheck.sh || exit 1"] interval: 60s + timeout: 15s # clamd answers slowly while scanning retries: 5 start_period: 300s # the initial signature load is slow mem_limit: 2g # ClamAV genuinely needs this @@ -174,6 +185,7 @@ services: healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:3002/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 15s + timeout: 5s retries: 3 start_period: 20s mem_limit: 1g @@ -312,6 +324,16 @@ services: # TLS listener comes up without a certificate (section 8.2, trap 2). - traefik.http.routers.lm-mailhost.rule=Host(`${PRIMARY_MAIL_HOST}`) - traefik.http.routers.lm-mailhost.tls.certresolver=letsencrypt + # The one service that was never probed, despite being the only one users + # reach directly: a wedged Next process kept its container "running" and + # Traefik kept routing to it. The image ships no curl or wget, so this uses + # node's own fetch, as auth and storage do. + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s mem_limit: 512m networks: [backend, proxy] diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts index 1a8f5bf..41cdc6a 100644 --- a/services/auth/src/repository.ts +++ b/services/auth/src/repository.ts @@ -398,8 +398,14 @@ export async function preflightSummary(): Promise> { const domains = await query<{ name: string; dns_status: string; dane_enabled: boolean }>( `SELECT name, dns_status, dane_enabled FROM domains ORDER BY name`, ); + // Each check carries a key and its subject rather than a finished English + // sentence. The console builds the label from these, so a reader with the + // interface in Portuguese no longer sees "DNS records for example.com" + // spliced into an otherwise translated page. const checks = domains.map((d) => ({ - name: `DNS records for ${d.name}`, + key: "dnsRecordsFor", + target: d.name, + name: `DNS records for ${d.name}`, // kept for any client not yet using key/target status: d.dns_status === "VERIFIED" ? "PASS" : d.dns_status === "PENDING" ? "PENDING" : "FAIL", detail: d.dns_status, })); diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts index ce042b2..2558a5a 100644 --- a/services/auth/src/router.test.ts +++ b/services/auth/src/router.test.ts @@ -287,6 +287,24 @@ describeDb("auth API against a real database", () => { const dashboard = await get("/api/v1/admin/dashboard", verified.body.token); expect(dashboard.status).toBe(200); expect(dashboard.body).toHaveProperty("queue_depth"); + + // The console's sidebar reads its identity from here. It cannot use + // /api/v1/user/me, because extractToken picks the cookie from the URL + // prefix and an operator may reach /admin/login without ever having opened + // webmail. Until this route existed the sidebar printed a fixed + // "admin@lambdamail.local" with a hardcoded "SUPER_ADMIN (2FA)". + const identity = await get("/api/v1/admin/me", verified.body.token); + expect(identity.status).toBe(200); + expect(identity.body.email).toBe(adminEmail()); + expect(identity.body.role).toBe("SUPER_ADMIN"); + expect(identity.body.mfa_enrolled).toBe(true); + }); + + it("refuses /api/v1/admin/me to a user-surface token", async () => { + const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }); + const token = (login.body.token ?? login.body.challenge_token) as unknown as string; + const res = await get("/api/v1/admin/me", token); + expect(res.status).toBe(401); }); it("locks the account after repeated failures", async () => { diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index 6e1c3b7..5149e86 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -149,6 +149,7 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr } if (url === "/api/v1/user/password" && method === "PUT") return changePassword(req, res, session!); + if (url === "/api/v1/admin/me" && method === "GET") return me(res, session!); if (url === "/api/v1/admin/audit" && method === "GET") return adminAudit(res); if (url === "/api/v1/admin/mailboxes" && method === "POST") return adminCreateMailbox(req, res, session!); if (url === "/api/v1/admin/mailboxes/bulk-import" && method === "POST") return adminBulkImportMailboxes(req, res, session!); @@ -361,6 +362,16 @@ function logout(res: ServerResponse): void { // -------------------------------------------------------------- user area +/** + * The signed-in identity, served on both /user/me and /admin/me. + * + * Two paths rather than one because extractToken picks the cookie from the URL + * prefix: an /api/v1/user/* call reads lm_user_session. The admin console has + * only lm_admin_session - an operator can reach /admin/login directly without + * ever opening webmail - so it needs a path that reads the admin cookie. Both + * end up here, and the session they resolve from decides whose details come + * back, so neither can be used to look up somebody else. + */ async function me(res: ServerResponse, session: SessionTokenPayload): Promise { const mailbox = await repo.findMailboxByEmail(session.email); if (!mailbox) return sendJson(res, 404, { error: "NOT_FOUND", message: "Mailbox not found" }); @@ -696,7 +707,17 @@ async function adminReconcileDomain(req: IncomingMessage, res: ServerResponse, s } await repo.recordAudit(session.sub, clientIp(req), "domain.reconcile", "domain", domainId, {}); - sendJson(res, 200, { ok: true, records_verified: 13, status: "VERIFIED" }); + + // Reports what this actually did, which is record the request. It used to + // answer {records_verified: 13, status: "VERIFIED"} unconditionally - for any + // domain id, including one that does not exist - and the console printed that + // as "all 13 DNS records verified". Nothing here resolves a name, so nothing + // here may claim a record was checked. + sendJson(res, 200, { + ok: true, + status: "REQUESTED", + message: "Reconciliation was recorded. DNS records are not verified by this endpoint yet.", + }); } diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 40a3685..ba2f2c6 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -1,148 +1,286 @@ { - "common": { - "appName": "LambdaMail", - "userPortal": "Webmail", - "adminPortal": "Management Console", - "loading": "Loading...", - "save": "Save Changes", - "cancel": "Cancel", - "delete": "Delete", - "confirm": "Confirm", - "search": "Search", - "status": "Status", - "actions": "Actions", - "refresh": "Refresh", - "logout": "Log Out" + "admin": { + "addAlias": "Add alias", + "aliasesTitle": "Aliases", + "auditLog": "Audit log", + "backToWebmail": "Back to webmail", + "cancelJob": "Cancel", + "certificateState": "Certificate", + "checksPassed": "{passed} of {total} passed", + "createAccount": "Create account", + "createAlias": "Create a routing alias", + "csvPreview": "Preview", + "dashboardSubtitle": "Live figures read from the database.", + "dashboardTitle": "Overview", + "dkimAlgorithm": "Key type", + "dkimOverlap": "Both keys sign outbound mail for {days} days so nothing fails while DNS propagates.", + "dkimPublishRecord": "Publish this record in DNS:", + "dkimRotate": "Generate a new key", + "dkimSelector": "Selector", + "dkimTitle": "DKIM key rotation", + "dmarcSubtitle": "Breakdown of the aggregate XML reports that have been ingested.", + "dmarcTitle": "DMARC reports", + "dnsRecordsFor": "DNS records for {domain}", + "dnsStatusDrift": "Drift", + "dnsStatusPartial": "Partial", + "dnsStatusVerified": "Verified", + "domainName": "Domain name", + "domainsTitle": "Domains", + "expiresInDays": "Expires in {days} days", + "freezeDomain": "Freeze domain", + "greylistScore": "Greylist score", + "headerScore": "Header-tagging score", + "jobCancelled": "Job {id} was cancelled.", + "jobRetried": "Job {id} was queued for another attempt.", + "lastChecked": "Last checked: {when}", + "lastReload": "Last reload", + "mailHost": "Mail host", + "mailboxCount": "{count} mailboxes", + "mailboxesSubtitle": "Accounts, aliases, CSV import and the second-factor policy.", + "mailboxesTitle": "Mailboxes", + "mfaAdmins": "Required for admins", + "mfaEveryone": "Required for everyone", + "mfaOptional": "Optional", + "mfaPolicyIntro": "Choose how strictly this domain requires a second factor.", + "mfaPolicyTitle": "Second-factor requirement", + "noCertificate": "No certificate is loaded for this host.", + "noChecks": "No checks reported.", + "noDomains": "No domains have been onboarded yet.", + "onboardIntro": "Creates the domain and its standard RFC 2142 aliases, pointed at your address.", + "onboardTitle": "Add a domain", + "openAdmin": "Admin console", + "preflightTitle": "Readiness checks", + "queueEmpty": "The outbound queue is empty.", + "queueSubtitle": "Inspect the outbound queue, retry a job, or cancel one.", + "queueTitle": "Outbound queue", + "readinessIntro": "Checks the server reports about itself before it carries production mail.", + "reconcileDns": "Reconcile DNS", + "reconcileNotImplemented": "Per-record DNS verification is not implemented yet: this records the request and re-reads the stored status, it does not query a resolver.", + "rejectScore": "Reject score", + "retryJob": "Retry", + "rspamdIntro": "Scores that trigger greylisting, header tagging and rejection.", + "rspamdSaved": "Thresholds saved.", + "saveThresholds": "Save thresholds", + "securitySubtitle": "Readiness checks, TLS state, spam thresholds and the audit trail.", + "securityTitle": "Security", + "tabAudit": "Audit trail", + "tabReadiness": "Readiness", + "tabRspamd": "Spam thresholds", + "tabTls": "TLS", + "tlsMode": "TLS mode", + "tlsTitle": "Certificate and transport", + "traceNoResults": "No trace found for that queue ID.", + "tracePlaceholder": "Queue ID", + "traceTitle": "Trace a message", + "unknownSni": "Unknown SNI requests", + "watcherHealthy": "Certificate watcher" }, "auth": { - "userLoginTitle": "Webmail Sign In", - "adminLoginTitle": "Admin Console Sign In", - "emailLabel": "Email Address", - "passwordLabel": "Password", - "signInButton": "Sign In", - "totpCodeLabel": "6-Digit Authenticator Code", - "verifyCodeButton": "Verify Code", + "adminLoginTitle": "Admin console", + "adminStepUpNote": "The admin console asks for your second factor again, even though you are already signed in to webmail.", + "authFailed": "Authentication failed", + "cantScanQr": "Cannot scan? Enter this key manually:", + "emailLabel": "Email address", + "enrolmentFailed": "Could not start the second-factor setup", + "enrolmentIntro": "The admin console requires a second factor. Set one up now to continue.", + "genericError": "Something went wrong", "invalidCredentials": "Invalid email or password", - "mfaRequired": "Two-factor authentication code required" + "mfaRequired": "Enter the six-digit code from your authenticator app.", + "passwordLabel": "Password", + "qrCodeAlt": "QR code for two-factor setup", + "scanQr": "Scan this code with Google Authenticator, Aegis, Authy or 1Password.", + "signInButton": "Sign in", + "signOutOfAdmin": "Leave admin", + "totpCodeLabel": "Authentication code", + "userLoginTitle": "Sign in to your mailbox", + "verificationFailed": "Verification failed", + "verifyCodeButton": "Verify code" }, - "mail": { - "inbox": "Inbox", - "sent": "Sent", - "drafts": "Drafts", - "trash": "Trash", - "junk": "Spam", - "archive": "Archive", - "compose": "Compose Email", - "noMessages": "No messages in this folder", - "unreadCount": "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}", - "loadImages": "Load Remote Images", - "reply": "Reply", - "forward": "Forward", - "undoSend": "Undo Send ({seconds}s remaining)" - }, - "settings": { - "title": "Account Settings", - "security": "Security & 2FA", - "appPasswords": "App Passwords", - "sessions": "Active Sessions", - "mfaStatus": "Two-Factor Authentication Status", - "mfaEnabled": "2FA is Active", - "mfaDisabled": "2FA is Disabled", - "enableMfa": "Enable 2FA", - "generateAppPassword": "Generate App Password", - "recoveryCodesTitle": "Recovery Codes", - "saveRecoveryCodes": "Save these recovery codes in a safe place. They will not be shown again.", - "language": "Language", - "signOut": "Sign out", - "langEn": "English", - "langPtBR": "Portuguese (Brazil)", - "langEs": "Spanish" - }, - "admin": { - "dashboardTitle": "System Dashboard", - "domainsTitle": "Domains & DNS (13 Records)", - "mailboxesTitle": "Mailboxes & Accounts", - "aliasesTitle": "Aliases & Routing", - "queueTitle": "Outbound Delivery Queue", - "dmarcTitle": "DMARC Analytics", - "securityTitle": "Security & Audit Log", - "preflightTitle": "Preflight Diagnostics", - "reconcileDns": "Reconcile DNS Records", - "dnsStatusVerified": "VERIFIED", - "dnsStatusDrift": "DRIFT DETECTED", - "dnsStatusPartial": "PARTIAL", - "retryJob": "Retry Delivery", - "cancelJob": "Cancel Job", - "freezeDomain": "Freeze Destination" + "common": { + "actions": "Actions", + "add": "Add", + "adminPortal": "Admin", + "appName": "LambdaMail", + "back": "Back", + "cancel": "Cancel", + "confirm": "Confirm", + "continue": "Continue", + "copied": "Copied", + "copy": "Copy", + "delete": "Delete", + "loading": "Loading...", + "logout": "Sign out", + "never": "Never", + "none": "None", + "refresh": "Refresh", + "retry": "Retry", + "save": "Save", + "search": "Search", + "status": "Status", + "unavailable": "Unavailable", + "userPortal": "Webmail" }, "errors": { - "unauthorized": "Session expired or unauthorized", - "forbidden": "Access forbidden for this account surface", - "serverError": "Internal server error occurred" + "forbidden": "You do not have access to this", + "loadFailed": "Could not load the data", + "serverError": "Something went wrong on the server", + "unauthorized": "Please sign in again" + }, + "mail": { + "addFiles": "Add files...", + "allMessages": "All", + "archive": "Archive", + "attachments": "Attachments ({count})", + "attachmentsFilter": "With attachments", + "attachmentsUnsupported": "Attachments are listed by name only; file upload is not wired up yet.", + "backToList": "Back to the message list", + "bold": "Bold", + "bulletList": "Bulleted list", + "compose": "Compose", + "discard": "Discard", + "draftSaved": "Draft saved automatically", + "drafts": "Drafts", + "forward": "Forward", + "hideCcBcc": "- Hide Cc/Bcc", + "inbox": "Inbox", + "insertLink": "Insert link", + "insertUrl": "Enter the URL:", + "italic": "Italic", + "junk": "Spam", + "loadImages": "Load images", + "noMessages": "No messages", + "numberedList": "Numbered list", + "reply": "Reply", + "sendMessage": "Send message", + "sending": "Sending...", + "sent": "Sent", + "showCcBcc": "+ Add Cc/Bcc", + "strikethrough": "Strikethrough", + "trash": "Trash", + "underline": "Underline", + "undoSend": "Sending in {seconds}s", + "undoSendAction": "Undo", + "unreadCount": "{count} unread", + "unreadFilter": "Unread" + }, + "settings": { + "actionDiscard": "Discard", + "actionFlag": "Flag message", + "actionMove": "Move to Archive", + "addRule": "Add rule", + "appPasswordPlaceholder": "Thunderbird / Outlook Mobile", + "appPasswords": "App passwords", + "autoReplySaveFailed": "Could not save the auto-reply", + "enableMfa": "Enable two-factor", + "fieldFrom": "From", + "fieldHeader": "Header", + "fieldSubject": "Subject", + "generateAppPassword": "Generate", + "langEn": "English", + "langEs": "Español", + "langPtBR": "Português (Brasil)", + "language": "Language", + "matchContains": "contains", + "matchEquals": "is exactly", + "matchRegex": "matches regex", + "mfaDisabled": "Disabled", + "mfaEnabled": "Enabled", + "mfaStatus": "Two-factor status", + "recoveryCodesTitle": "Recovery codes", + "ruleIf": "IF", + "ruleThen": "THEN", + "saveAutoReply": "Save auto-reply", + "saveRecoveryCodes": "Save these somewhere safe. Each one works once, and they are shown only now.", + "security": "Security", + "sessions": "Sessions", + "settingsSaved": "Settings saved.", + "sieveIntro": "Rules that sort incoming mail automatically.", + "sieveTitle": "Filters (Sieve)", + "signOut": "Sign out", + "signatureIntro": "Appended automatically to the messages you compose.", + "signaturePlaceholder": "Kind regards,\nYour name\nYour role", + "signatureSaveFailed": "Could not save the signature", + "signatureSaved": "Signature saved.", + "signatureTitle": "Email signature", + "sixDigitCode": "Six-digit code", + "subtitle": "Two-factor authentication, filters, auto-reply and your signature.", + "title": "Settings", + "twoFactorIntro": "Protect your account with Google Authenticator, Aegis, Authy or 1Password.", + "vacationBodyLabel": "Reply message", + "vacationDefaultBody": "Thank you for your message. I am currently away and will reply when I return.", + "vacationDefaultSubject": "Out of office", + "vacationIntro": "Reply automatically to incoming mail while you are away.", + "vacationSubjectLabel": "Reply subject", + "vacationTitle": "Out-of-office auto-reply", + "valuePlaceholder": "Value to match..." }, "ui": { - "accountStatus": "Account Status", + "accountStatus": "Account status", "actions": "Actions", - "active": "ACTIVE", - "activeSessions": "Active Webmail Sessions", - "actor": "Actor / Account", - "actualRecord": "Actual DNS Record (Cloudflare)", - "allChecksPassed": "ALL CHECKS PASSED", - "appPasswordGenerated": "App Password Generated:", - "appPasswordsSection": "App Passwords (IMAP / SMTP Clients)", + "active": "Active", + "activeSessions": "Active sessions", + "actor": "Actor", + "actualRecord": "Actual value", + "allChecksPassed": "All checks passed", + "appPasswordGenerated": "App password generated", + "appPasswordsSection": "App passwords", "attempts": "Attempts", - "clientIp": "Client IP", - "copyPasswordNow": "Copy this password now. It will not be shown again.", - "coreInfra": "Core Infrastructure Status", - "currentSession": "Current Webmail Browser Session", - "daneVerified": "DANE / MTA-STS verified", - "destination": "Destination", - "diskUsed": "Disk Capacity Used", - "dkimResult": "DKIM Result", - "dkimAlignment": "DKIM Signature Alignment", - "dmarcEnforcement": "DMARC Policy Enforcement", - "domainVerification": "Domain Verification Status", - "emailAddress": "Email Address", - "eventAction": "Event Action", - "expectedValue": "Expected Specification Value", - "healthy": "HEALTHY", - "imapEngine": "IMAP Protocol Engine (143/993)", - "inboundVolume": "Inbound Volume (24h)", - "jobId": "Job ID", - "lastError": "Last Error", - "liveMetrics": "Live operational metrics for LambdaMail mail node.", - "localDisk": "Local disk storage driver", - "preflightTitle": "Mail Server Preflight Diagnostics", - "messageBody": "Message Body", - "messageVolume": "Message Volume", - "noAppPasswords": "No app passwords generated yet.", - "online": "ONLINE", - "outboundDelivered": "Outbound Delivered (24h)", - "queueDepth": "Outbound Queue Depth", - "postgresRunner": "Postgres job runner active", - "provisionMailbox": "Provision New Mailbox Account", - "recipient": "Recipient", - "recordName": "Record Name", - "reportingOrg": "Reporting Organization", - "scanners": "Rspamd Anti-Spam Scanner & ClamAV", - "sender": "Sender", - "smtpListener": "SMTP Protocols Listener (25/587/465)", - "sourceIp": "Source IP", - "spfRatio": "SPF Alignment Pass Ratio", - "spfResult": "SPF Result", - "storageQuota": "Storage Quota", - "subject": "Subject", - "targetObject": "Target Object", - "timestamp": "Timestamp", - "toRecipients": "To (Recipients)", - "totalEvaluated": "Total Evaluated Messages", - "twoFactorSection": "Two-Factor Authentication (TOTP RFC 6238)", - "watching": "WATCHING", - "passwordChanged": "Password changed", - "signOutOthers": "Sign out other sessions", + "bounceRate": "Bounce rate", "changePassword": "Change password", + "clientIp": "Client IP", + "copyPasswordNow": "Copy it now. It is not shown again.", + "coreInfra": "Core services", "currentPassword": "Current password", - "newPassword": "New password (min. 12 characters)", - "toggleMenu": "Toggle navigation menu" + "currentSession": "This device", + "daneVerified": "DANE verified", + "destination": "Destination", + "diskUsed": "Storage used", + "dkimAlignment": "DKIM alignment", + "dkimResult": "DKIM", + "dmarcEnforcement": "DMARC policy", + "domainVerification": "Domain verification", + "domainsVerified": "Domains verified", + "emailAddress": "Email address", + "eventAction": "Action", + "expectedValue": "Expected value", + "healthy": "Healthy", + "imapEngine": "IMAP service", + "inboundVolume": "Received (24h)", + "jobId": "Job", + "lastError": "Last error", + "liveMetrics": "Live metrics", + "localDisk": "Local disk", + "mailboxesActive": "Active mailboxes", + "messageBody": "Message", + "messageVolume": "Messages", + "newPassword": "New password", + "noAppPasswords": "No app passwords yet.", + "noData": "No data yet", + "online": "Online", + "outboundDelivered": "Sent (24h)", + "passwordChanged": "Password changed.", + "postgresRunner": "Postgres-backed queue", + "preflightTitle": "Readiness", + "provisionMailbox": "Create mailbox", + "queueDepth": "Queue depth", + "recipient": "Recipient", + "recordName": "Record", + "reportingOrg": "Reporting organisation", + "scanners": "Spam and virus scanning", + "sender": "Sender", + "signOutOthers": "Sign out other devices", + "smtpListener": "SMTP service", + "sourceIp": "Source IP", + "spfRatio": "SPF pass rate", + "spfResult": "SPF", + "storageQuota": "Storage quota", + "subject": "Subject", + "targetObject": "Target", + "timestamp": "Time", + "toRecipients": "To", + "toggleMenu": "Toggle menu", + "totalEvaluated": "Messages evaluated", + "twoFactorSection": "Two-factor authentication", + "watching": "Watching" } } diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 2724db0..cd581ce 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -1,148 +1,286 @@ { - "common": { - "appName": "LambdaMail", - "userPortal": "Webmail", - "adminPortal": "Consola de Gestión", - "loading": "Cargando...", - "save": "Guardar Cambios", - "cancel": "Cancelar", - "delete": "Eliminar", - "confirm": "Confirmar", - "search": "Buscar", - "status": "Estado", - "actions": "Acciones", - "refresh": "Actualizar", - "logout": "Cerrar Sesión" + "admin": { + "addAlias": "Añadir apelido", + "aliasesTitle": "Alias", + "auditLog": "Registro de auditoría", + "backToWebmail": "Volver al webmail", + "cancelJob": "Cancelar", + "certificateState": "Certificado", + "checksPassed": "{passed} de {total} aprobadas", + "createAccount": "Crear cuenta", + "createAlias": "Crear un alias de enrutamiento", + "csvPreview": "Vista previa", + "dashboardSubtitle": "Cifras en vivo leídas de la base de datos.", + "dashboardTitle": "Resumen", + "dkimAlgorithm": "Tipo de clave", + "dkimOverlap": "Ambas claves firman el correo saliente durante {days} días para que nada falle mientras el DNS se propaga.", + "dkimPublishRecord": "Publique este registro en DNS:", + "dkimRotate": "Generar una nueva clave", + "dkimSelector": "Selector", + "dkimTitle": "Rotación de clave DKIM", + "dmarcSubtitle": "Desglose de los informes XML agregados procesados.", + "dmarcTitle": "Informes DMARC", + "dnsRecordsFor": "Registros DNS de {domain}", + "dnsStatusDrift": "Divergente", + "dnsStatusPartial": "Parcial", + "dnsStatusVerified": "Verificado", + "domainName": "Nombre del dominio", + "domainsTitle": "Dominios", + "expiresInDays": "Caduca en {days} días", + "freezeDomain": "Congelar dominio", + "greylistScore": "Puntuación de greylist", + "headerScore": "Puntuación de etiquetado de cabecera", + "jobCancelled": "El trabajo {id} fue cancelado.", + "jobRetried": "El trabajo {id} se volvió a poner en cola para otro intento.", + "lastChecked": "Última comprobación: {when}", + "lastReload": "Última recarga", + "mailHost": "Host de correo", + "mailboxCount": "{count} buzones", + "mailboxesSubtitle": "Cuentas, alias, importación CSV y la política de segundo factor.", + "mailboxesTitle": "Buzones", + "mfaAdmins": "Obligatorio para administradores", + "mfaEveryone": "Obligatorio para todos", + "mfaOptional": "Opcional", + "mfaPolicyIntro": "Elija con qué rigor este dominio exige un segundo factor.", + "mfaPolicyTitle": "Requisito de segundo factor", + "noCertificate": "No hay certificado cargado para este host.", + "noChecks": "No se informaron verificaciones.", + "noDomains": "Aún no se ha dado de alta ningún dominio.", + "onboardIntro": "Crea el dominio y sus alias estándar RFC 2142, apuntados a su dirección.", + "onboardTitle": "Añadir un dominio", + "openAdmin": "Consola de administración", + "preflightTitle": "Verificaciones de preparación", + "queueEmpty": "La cola de salida está vacía.", + "queueSubtitle": "Inspeccione la cola de salida, reintente un trabajo o cancele uno.", + "queueTitle": "Cola de salida", + "readinessIntro": "Verificaciones que el servidor informa sobre sí mismo antes de transportar correo en producción.", + "reconcileDns": "Reconciliar DNS", + "reconcileNotImplemented": "La verificación de registros DNS individuales aún no está implementada: esto registra la solicitud y relee el estado almacenado, no consulta un resolutor.", + "rejectScore": "Puntuación de rechazo", + "retryJob": "Reintentar", + "rspamdIntro": "Puntuaciones que activan greylisting, etiquetado de cabecera y rechazo.", + "rspamdSaved": "Umbrales guardados.", + "saveThresholds": "Guardar umbrales", + "securitySubtitle": "Verificaciones de preparación, estado TLS, umbrales de spam y el registro de auditoría.", + "securityTitle": "Seguridad", + "tabAudit": "Registro de auditoría", + "tabReadiness": "Preparación", + "tabRspamd": "Umbrales de spam", + "tabTls": "TLS", + "tlsMode": "Modo TLS", + "tlsTitle": "Certificado y transporte", + "traceNoResults": "No se encontró rastro para ese ID de cola.", + "tracePlaceholder": "ID de la cola", + "traceTitle": "Rastrear un mensaje", + "unknownSni": "Solicitudes SNI desconocidas", + "watcherHealthy": "Monitor de certificado" }, "auth": { - "userLoginTitle": "Iniciar Sesión en Webmail", - "adminLoginTitle": "Iniciar Sesión en Consola Admin", - "emailLabel": "Dirección de Correo", + "adminLoginTitle": "Consola de administración", + "adminStepUpNote": "La consola de administración vuelve a pedir su segundo factor, aunque ya haya iniciado sesión en el webmail.", + "authFailed": "Fallo en la autenticación", + "cantScanQr": "¿No puede escanear? Introduzca esta clave manualmente:", + "emailLabel": "Dirección de correo", + "enrolmentFailed": "No se pudo iniciar la configuración del segundo factor", + "enrolmentIntro": "La consola de administración requiere un segundo factor. Configure uno ahora para continuar.", + "genericError": "Algo salió mal", + "invalidCredentials": "Correo o contraseña inválidos", + "mfaRequired": "Introduzca el código de seis dígitos de su aplicación de autenticación.", "passwordLabel": "Contraseña", - "signInButton": "Iniciar Sesión", - "totpCodeLabel": "Código del Autenticador (6 dígitos)", - "verifyCodeButton": "Verificar Código", - "invalidCredentials": "Correo o contraseña no válidos", - "mfaRequired": "Código de autenticación de dos factores requerido" + "qrCodeAlt": "Código QR para configurar la verificación en dos pasos", + "scanQr": "Escanee este código con Google Authenticator, Aegis, Authy o 1Password.", + "signInButton": "Iniciar sesión", + "signOutOfAdmin": "Salir de administración", + "totpCodeLabel": "Código de autenticación", + "userLoginTitle": "Acceda a su buzón", + "verificationFailed": "Fallo en la verificación", + "verifyCodeButton": "Verificar código" }, - "mail": { - "inbox": "Bandeja de Entrada", - "sent": "Enviados", - "drafts": "Borradores", - "trash": "Papelera", - "junk": "Spam", - "archive": "Archivo", - "compose": "Redactar Correo", - "noMessages": "No hay mensajes en esta carpeta", - "unreadCount": "{count, plural, =0 {Sin mensajes no leídos} one {# mensaje no leído} other {# mensajes no leídos}}", - "loadImages": "Cargar Imágenes Remotas", - "reply": "Responder", - "forward": "Reenviar", - "undoSend": "Deshacer Envío ({seconds}s restantes)" - }, - "settings": { - "title": "Configuración de la Cuenta", - "security": "Seguridad y 2FA", - "appPasswords": "Contraseñas de Aplicación", - "sessions": "Sesiones Activas", - "mfaStatus": "Estado de Autenticación de Dos Factores", - "mfaEnabled": "2FA está Activo", - "mfaDisabled": "2FA está Desactivado", - "enableMfa": "Activar 2FA", - "generateAppPassword": "Generar Contraseña de Aplicación", - "recoveryCodesTitle": "Códigos de Recuperación", - "saveRecoveryCodes": "Guarde estos códigos de recuperación en un lugar seguro. No se volverán a mostrar.", - "language": "Idioma", - "signOut": "Cerrar sesión", - "langEn": "Inglés", - "langPtBR": "Portugués (Brasil)", - "langEs": "Español" - }, - "admin": { - "dashboardTitle": "Panel del Sistema", - "domainsTitle": "Dominios y DNS (13 Registros)", - "mailboxesTitle": "Buzones y Cuentas", - "aliasesTitle": "Alias y Enrutamiento", - "queueTitle": "Cola de Entrega de Salida", - "dmarcTitle": "Análisis DMARC", - "securityTitle": "Seguridad y Registro de Auditoría", - "preflightTitle": "Diagnóstico Preflight", - "reconcileDns": "Reconciliar Registros DNS", - "dnsStatusVerified": "VERIFICADO", - "dnsStatusDrift": "DESVÍO DETECTADO", - "dnsStatusPartial": "PARCIAL", - "retryJob": "Reintentar Entrega", - "cancelJob": "Cancelar Trabajo", - "freezeDomain": "Congelar Destino" + "common": { + "actions": "Acciones", + "add": "Añadir", + "adminPortal": "Administración", + "appName": "LambdaMail", + "back": "Volver", + "cancel": "Cancelar", + "confirm": "Confirmar", + "continue": "Continuar", + "copied": "Copiado", + "copy": "Copiar", + "delete": "Eliminar", + "loading": "Cargando...", + "logout": "Cerrar sesión", + "never": "Nunca", + "none": "Ninguno", + "refresh": "Actualizar", + "retry": "Reintentar", + "save": "Guardar", + "search": "Buscar", + "status": "Estado", + "unavailable": "No disponible", + "userPortal": "Webmail" }, "errors": { - "unauthorized": "Sesión expirada o no autorizada", - "forbidden": "Acceso prohibido para esta superficie de cuenta", - "serverError": "Ocurrió un error interno en el servidor" + "forbidden": "No tiene acceso a esto", + "loadFailed": "No se pudieron cargar los datos", + "serverError": "Algo salió mal en el servidor", + "unauthorized": "Inicie sesión de nuevo" + }, + "mail": { + "addFiles": "Añadir archivos...", + "allMessages": "Todas", + "archive": "Archivo", + "attachments": "Adjuntos ({count})", + "attachmentsFilter": "Con adjuntos", + "attachmentsUnsupported": "Los adjuntos se listan solo por nombre; la subida de archivos aún no está implementada.", + "backToList": "Volver a la lista de mensajes", + "bold": "Negrita", + "bulletList": "Lista con viñetas", + "compose": "Redactar", + "discard": "Descartar", + "draftSaved": "Borrador guardado automáticamente", + "drafts": "Borradores", + "forward": "Reenviar", + "hideCcBcc": "- Ocultar Cc/Cco", + "inbox": "Bandeja de entrada", + "insertLink": "Insertar enlace", + "insertUrl": "Introduzca la URL:", + "italic": "Cursiva", + "junk": "Spam", + "loadImages": "Cargar imágenes", + "noMessages": "Sin mensajes", + "numberedList": "Lista numerada", + "reply": "Responder", + "sendMessage": "Enviar mensaje", + "sending": "Enviando...", + "sent": "Enviados", + "showCcBcc": "+ Añadir Cc/Cco", + "strikethrough": "Tachado", + "trash": "Papelera", + "underline": "Subrayado", + "undoSend": "Enviando en {seconds}s", + "undoSendAction": "Deshacer", + "unreadCount": "{count} sin leer", + "unreadFilter": "Sin leer" + }, + "settings": { + "actionDiscard": "Descartar", + "actionFlag": "Marcar mensaje", + "actionMove": "Mover al Archivo", + "addRule": "Añadir regla", + "appPasswordPlaceholder": "Thunderbird / Outlook Mobile", + "appPasswords": "Contraseñas de aplicación", + "autoReplySaveFailed": "No se pudo guardar la respuesta automática", + "enableMfa": "Activar verificación en dos pasos", + "fieldFrom": "De", + "fieldHeader": "Cabecera", + "fieldSubject": "Asunto", + "generateAppPassword": "Generar", + "langEn": "English", + "langEs": "Español", + "langPtBR": "Português (Brasil)", + "language": "Idioma", + "matchContains": "contiene", + "matchEquals": "es exactamente", + "matchRegex": "coincide con regex", + "mfaDisabled": "Desactivada", + "mfaEnabled": "Activada", + "mfaStatus": "Estado de la verificación en dos pasos", + "recoveryCodesTitle": "Códigos de recuperación", + "ruleIf": "SI", + "ruleThen": "ENTONCES", + "saveAutoReply": "Guardar respuesta automática", + "saveRecoveryCodes": "Guárdelos en un lugar seguro. Cada uno funciona una sola vez y solo se muestran ahora.", + "security": "Seguridad", + "sessions": "Sesiones", + "settingsSaved": "Configuración guardada.", + "sieveIntro": "Reglas que organizan el correo entrante automáticamente.", + "sieveTitle": "Filtros (Sieve)", + "signOut": "Cerrar sesión", + "signatureIntro": "Se añade automáticamente a los mensajes que redacta.", + "signaturePlaceholder": "Saludos cordiales,\nSu nombre\nSu cargo", + "signatureSaveFailed": "No se pudo guardar la firma", + "signatureSaved": "Firma guardada.", + "signatureTitle": "Firma de correo", + "sixDigitCode": "Código de seis dígitos", + "subtitle": "Verificación en dos pasos, filtros, respuesta automática y su firma.", + "title": "Configuración", + "twoFactorIntro": "Proteja su cuenta con Google Authenticator, Aegis, Authy o 1Password.", + "vacationBodyLabel": "Mensaje de la respuesta", + "vacationDefaultBody": "Gracias por su mensaje. Estoy ausente en este momento y responderé a mi regreso.", + "vacationDefaultSubject": "Fuera de la oficina", + "vacationIntro": "Responde automáticamente al correo entrante mientras esté ausente.", + "vacationSubjectLabel": "Asunto de la respuesta", + "vacationTitle": "Respuesta automática de ausencia", + "valuePlaceholder": "Valor a coincidir..." }, "ui": { "accountStatus": "Estado de la cuenta", "actions": "Acciones", - "active": "ACTIVA", - "activeSessions": "Sesiones activas del webmail", - "actor": "Autor / cuenta", - "actualRecord": "Registro DNS actual (Cloudflare)", - "allChecksPassed": "TODAS LAS COMPROBACIONES PASARON", - "appPasswordGenerated": "Contrasena de aplicacion generada:", - "appPasswordsSection": "Contrasenas de aplicacion (clientes IMAP / SMTP)", + "active": "Activo", + "activeSessions": "Sesiones activas", + "actor": "Autor", + "actualRecord": "Valor actual", + "allChecksPassed": "Todas las verificaciones pasaron", + "appPasswordGenerated": "Contraseña de aplicación generada", + "appPasswordsSection": "Contraseñas de aplicación", "attempts": "Intentos", + "bounceRate": "Tasa de rebote", + "changePassword": "Cambiar contraseña", "clientIp": "IP del cliente", - "copyPasswordNow": "Copie esta contrasena ahora. No se mostrara de nuevo.", - "coreInfra": "Estado de la infraestructura", - "currentSession": "Sesion actual de este navegador", - "daneVerified": "DANE / MTA-STS verificado", + "copyPasswordNow": "Cópiela ahora. No se mostrará de nuevo.", + "coreInfra": "Servicios principales", + "currentPassword": "Contraseña actual", + "currentSession": "Este dispositivo", + "daneVerified": "DANE verificado", "destination": "Destino", - "diskUsed": "Uso de disco", - "dkimResult": "Resultado DKIM", - "dkimAlignment": "Alineacion de la firma DKIM", - "dmarcEnforcement": "Aplicacion de la politica DMARC", - "domainVerification": "Estado de la verificacion del dominio", - "emailAddress": "Direccion de correo", - "eventAction": "Accion registrada", - "expectedValue": "Valor esperado por la especificacion", - "healthy": "SALUDABLE", - "imapEngine": "Servidor IMAP (143/993)", - "inboundVolume": "Volumen de entrada (24h)", - "jobId": "ID del trabajo", - "lastError": "Ultimo error", - "liveMetrics": "Metricas operativas del nodo de correo.", - "localDisk": "Almacenamiento en disco local", - "preflightTitle": "Diagnostico de preflight del servidor", - "messageBody": "Cuerpo del mensaje", - "messageVolume": "Volumen de mensajes", - "noAppPasswords": "Aun no se generaron contrasenas de aplicacion.", - "online": "EN LINEA", - "outboundDelivered": "Entregados en salida (24h)", - "queueDepth": "Profundidad de la cola", - "postgresRunner": "Ejecutor de tareas Postgres activo", - "provisionMailbox": "Crear nuevo buzon", + "diskUsed": "Almacenamiento usado", + "dkimAlignment": "Alineación DKIM", + "dkimResult": "DKIM", + "dmarcEnforcement": "Política DMARC", + "domainVerification": "Verificación de dominio", + "domainsVerified": "Dominios verificados", + "emailAddress": "Dirección de correo", + "eventAction": "Acción", + "expectedValue": "Valor esperado", + "healthy": "Correcto", + "imapEngine": "Servicio IMAP", + "inboundVolume": "Recibidas (24h)", + "jobId": "Trabajo", + "lastError": "Último error", + "liveMetrics": "Métricas en vivo", + "localDisk": "Disco local", + "mailboxesActive": "Buzones activos", + "messageBody": "Mensaje", + "messageVolume": "Mensajes", + "newPassword": "Nueva contraseña", + "noAppPasswords": "Aún no hay contraseñas de aplicación.", + "noData": "Aún sin datos", + "online": "En línea", + "outboundDelivered": "Enviadas (24h)", + "passwordChanged": "Contraseña cambiada.", + "postgresRunner": "Cola basada en Postgres", + "preflightTitle": "Preparación", + "provisionMailbox": "Crear buzón", + "queueDepth": "Tamaño de la cola", "recipient": "Destinatario", - "recordName": "Nombre del registro", - "reportingOrg": "Organizacion que informo", - "scanners": "Rspamd (antispam) y ClamAV", + "recordName": "Registro", + "reportingOrg": "Organización informante", + "scanners": "Análisis de spam y virus", "sender": "Remitente", - "smtpListener": "Servidor SMTP (25/587/465)", + "signOutOthers": "Cerrar sesión en otros dispositivos", + "smtpListener": "Servicio SMTP", "sourceIp": "IP de origen", - "spfRatio": "Tasa de alineacion SPF", - "spfResult": "Resultado SPF", + "spfRatio": "Tasa de aprobación SPF", + "spfResult": "SPF", "storageQuota": "Cuota de almacenamiento", "subject": "Asunto", - "targetObject": "Objeto destino", - "timestamp": "Fecha y hora", - "toRecipients": "Para (destinatarios)", - "totalEvaluated": "Total de mensajes evaluados", - "twoFactorSection": "Autenticacion en dos pasos (TOTP RFC 6238)", - "watching": "OBSERVANDO", - "passwordChanged": "Contrasena cambiada", - "signOutOthers": "Cerrar las otras sesiones", - "changePassword": "Cambiar contrasena", - "currentPassword": "Contrasena actual", - "newPassword": "Nueva contrasena (min. 12 caracteres)", - "toggleMenu": "Abrir o cerrar el menu" + "targetObject": "Objetivo", + "timestamp": "Hora", + "toRecipients": "Para", + "toggleMenu": "Alternar menú", + "totalEvaluated": "Mensajes evaluados", + "twoFactorSection": "Verificación en dos pasos", + "watching": "Vigilando" } } diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 6c76063..c4e22fa 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -1,148 +1,286 @@ { - "common": { - "appName": "LambdaMail", - "userPortal": "Webmail", - "adminPortal": "Console de Gestão", - "loading": "Carregando...", - "save": "Salvar Alterações", - "cancel": "Cancelar", - "delete": "Excluir", - "confirm": "Confirmar", - "search": "Buscar", - "status": "Status", - "actions": "Ações", - "refresh": "Atualizar", - "logout": "Sair" + "admin": { + "addAlias": "Adicionar apelido", + "aliasesTitle": "Apelidos", + "auditLog": "Trilha de auditoria", + "backToWebmail": "Voltar ao webmail", + "cancelJob": "Cancelar", + "certificateState": "Certificado", + "checksPassed": "{passed} de {total} aprovadas", + "createAccount": "Criar conta", + "createAlias": "Criar um apelido de roteamento", + "csvPreview": "Pré-visualizar", + "dashboardSubtitle": "Números ao vivo lidos do banco de dados.", + "dashboardTitle": "Visão geral", + "dkimAlgorithm": "Tipo de chave", + "dkimOverlap": "As duas chaves assinam as mensagens enviadas por {days} dias, para nada falhar enquanto o DNS propaga.", + "dkimPublishRecord": "Publique este registro no DNS:", + "dkimRotate": "Gerar uma nova chave", + "dkimSelector": "Seletor", + "dkimTitle": "Rotação de chave DKIM", + "dmarcSubtitle": "Detalhamento dos relatórios XML agregados que foram processados.", + "dmarcTitle": "Relatórios DMARC", + "dnsRecordsFor": "Registros DNS de {domain}", + "dnsStatusDrift": "Divergente", + "dnsStatusPartial": "Parcial", + "dnsStatusVerified": "Verificado", + "domainName": "Nome do domínio", + "domainsTitle": "Domínios", + "expiresInDays": "Expira em {days} dias", + "freezeDomain": "Congelar domínio", + "greylistScore": "Pontuação de greylist", + "headerScore": "Pontuação de marcação de cabeçalho", + "jobCancelled": "O trabalho {id} foi cancelado.", + "jobRetried": "O trabalho {id} foi recolocado na fila para nova tentativa.", + "lastChecked": "Última verificação: {when}", + "lastReload": "Última recarga", + "mailHost": "Host de e-mail", + "mailboxCount": "{count} caixas de e-mail", + "mailboxesSubtitle": "Contas, apelidos, importação CSV e a política de segundo fator.", + "mailboxesTitle": "Caixas de e-mail", + "mfaAdmins": "Obrigatório para administradores", + "mfaEveryone": "Obrigatório para todos", + "mfaOptional": "Opcional", + "mfaPolicyIntro": "Escolha com que rigor este domínio exige um segundo fator.", + "mfaPolicyTitle": "Exigência de segundo fator", + "noCertificate": "Nenhum certificado carregado para este host.", + "noChecks": "Nenhuma verificação relatada.", + "noDomains": "Nenhum domínio foi cadastrado ainda.", + "onboardIntro": "Cria o domínio e os apelidos padrão da RFC 2142, apontados para o seu endereço.", + "onboardTitle": "Adicionar um domínio", + "openAdmin": "Console de administração", + "preflightTitle": "Verificações de prontidão", + "queueEmpty": "A fila de saída está vazia.", + "queueSubtitle": "Inspecione a fila de saída, tente reenviar um trabalho ou cancele um.", + "queueTitle": "Fila de saída", + "readinessIntro": "Verificações que o servidor relata sobre si mesmo antes de transportar mensagens em produção.", + "reconcileDns": "Reconciliar DNS", + "reconcileNotImplemented": "A verificação de registros DNS individuais ainda não foi implementada: isto registra a solicitação e relê o status armazenado, não consulta um resolvedor.", + "rejectScore": "Pontuação de rejeição", + "retryJob": "Tentar novamente", + "rspamdIntro": "Pontuações que acionam greylisting, marcação de cabeçalho e rejeição.", + "rspamdSaved": "Limites salvos.", + "saveThresholds": "Salvar limites", + "securitySubtitle": "Verificações de prontidão, estado do TLS, limites de spam e a trilha de auditoria.", + "securityTitle": "Segurança", + "tabAudit": "Trilha de auditoria", + "tabReadiness": "Prontidão", + "tabRspamd": "Limites de spam", + "tabTls": "TLS", + "tlsMode": "Modo TLS", + "tlsTitle": "Certificado e transporte", + "traceNoResults": "Nenhum rastro encontrado para esse ID de fila.", + "tracePlaceholder": "ID da fila", + "traceTitle": "Rastrear uma mensagem", + "unknownSni": "Requisições SNI desconhecidas", + "watcherHealthy": "Monitor de certificado" }, "auth": { - "userLoginTitle": "Entrar no Webmail", - "adminLoginTitle": "Entrar no Console Admin", - "emailLabel": "Endereço de E-mail", - "passwordLabel": "Senha", - "signInButton": "Entrar", - "totpCodeLabel": "Código do Autenticador (6 dígitos)", - "verifyCodeButton": "Verificar Código", + "adminLoginTitle": "Console de administração", + "adminStepUpNote": "O console de administração pede o segundo fator novamente, mesmo você já estando conectado ao webmail.", + "authFailed": "Falha na autenticação", + "cantScanQr": "Não consegue escanear? Digite esta chave manualmente:", + "emailLabel": "Endereço de e-mail", + "enrolmentFailed": "Não foi possível iniciar a configuração do segundo fator", + "enrolmentIntro": "O console de administração exige um segundo fator. Configure um agora para continuar.", + "genericError": "Algo deu errado", "invalidCredentials": "E-mail ou senha inválidos", - "mfaRequired": "Código de autenticação em dois fatores obrigatório" + "mfaRequired": "Digite o código de seis dígitos do seu aplicativo autenticador.", + "passwordLabel": "Senha", + "qrCodeAlt": "Código QR para configurar a verificação em duas etapas", + "scanQr": "Escaneie este código com o Google Authenticator, Aegis, Authy ou 1Password.", + "signInButton": "Entrar", + "signOutOfAdmin": "Sair da administração", + "totpCodeLabel": "Código de autenticação", + "userLoginTitle": "Entre na sua caixa de e-mail", + "verificationFailed": "Falha na verificação", + "verifyCodeButton": "Verificar código" }, - "mail": { - "inbox": "Caixa de Entrada", - "sent": "Enviados", - "drafts": "Rascunhos", - "trash": "Lixeira", - "junk": "Spam", - "archive": "Arquivo", - "compose": "Escrever E-mail", - "noMessages": "Nenhuma mensagem nesta pasta", - "unreadCount": "{count, plural, =0 {Nenhuma mensagem não lida} one {# mensagem não lida} other {# mensagens não lidas}}", - "loadImages": "Carregar Imagens Remotas", - "reply": "Responder", - "forward": "Encaminhar", - "undoSend": "Desfazer Envio ({seconds}s restantes)" - }, - "settings": { - "title": "Configurações da Conta", - "security": "Segurança e 2FA", - "appPasswords": "Senhas de Aplicativo", - "sessions": "Sessões Ativas", - "mfaStatus": "Status da Autenticação em Dois Fatores", - "mfaEnabled": "2FA está Ativo", - "mfaDisabled": "2FA está Desativado", - "enableMfa": "Ativar 2FA", - "generateAppPassword": "Gerar Senha de Aplicativo", - "recoveryCodesTitle": "Códigos de Recuperação", - "saveRecoveryCodes": "Guarde estes códigos de recuperação em local seguro. Eles não serão exibidos novamente.", - "language": "Idioma", - "signOut": "Sair", - "langEn": "Inglês", - "langPtBR": "Português (Brasil)", - "langEs": "Espanhol" - }, - "admin": { - "dashboardTitle": "Painel do Sistema", - "domainsTitle": "Domínios e DNS (13 Registros)", - "mailboxesTitle": "Caixas Postais e Contas", - "aliasesTitle": "Aliases e Roteamento", - "queueTitle": "Fila de Entrega de Saída", - "dmarcTitle": "Análise DMARC", - "securityTitle": "Segurança e Log de Auditoria", - "preflightTitle": "Diagnóstico Preflight", - "reconcileDns": "Reconciliar Registros DNS", - "dnsStatusVerified": "VERIFICADO", - "dnsStatusDrift": "DESVIO DETECTADO", - "dnsStatusPartial": "PARCIAL", - "retryJob": "Tentar Entrega Novamente", - "cancelJob": "Cancelar Trabalho", - "freezeDomain": "Congelar Destino" + "common": { + "actions": "Ações", + "add": "Adicionar", + "adminPortal": "Administração", + "appName": "LambdaMail", + "back": "Voltar", + "cancel": "Cancelar", + "confirm": "Confirmar", + "continue": "Continuar", + "copied": "Copiado", + "copy": "Copiar", + "delete": "Excluir", + "loading": "Carregando...", + "logout": "Sair", + "never": "Nunca", + "none": "Nenhum", + "refresh": "Atualizar", + "retry": "Tentar novamente", + "save": "Salvar", + "search": "Buscar", + "status": "Status", + "unavailable": "Indisponível", + "userPortal": "Webmail" }, "errors": { - "unauthorized": "Sessão expirada ou não autorizada", - "forbidden": "Acesso proibido para esta superfície de conta", - "serverError": "Ocorreu um erro interno no servidor" + "forbidden": "Você não tem acesso a isto", + "loadFailed": "Não foi possível carregar os dados", + "serverError": "Algo deu errado no servidor", + "unauthorized": "Entre novamente" + }, + "mail": { + "addFiles": "Adicionar arquivos...", + "allMessages": "Todas", + "archive": "Arquivo", + "attachments": "Anexos ({count})", + "attachmentsFilter": "Com anexos", + "attachmentsUnsupported": "Anexos são listados apenas pelo nome; o envio de arquivos ainda não está implementado.", + "backToList": "Voltar para a lista de mensagens", + "bold": "Negrito", + "bulletList": "Lista com marcadores", + "compose": "Escrever", + "discard": "Descartar", + "draftSaved": "Rascunho salvo automaticamente", + "drafts": "Rascunhos", + "forward": "Encaminhar", + "hideCcBcc": "- Ocultar Cc/Cco", + "inbox": "Caixa de entrada", + "insertLink": "Inserir link", + "insertUrl": "Digite a URL:", + "italic": "Itálico", + "junk": "Spam", + "loadImages": "Carregar imagens", + "noMessages": "Nenhuma mensagem", + "numberedList": "Lista numerada", + "reply": "Responder", + "sendMessage": "Enviar mensagem", + "sending": "Enviando...", + "sent": "Enviados", + "showCcBcc": "+ Adicionar Cc/Cco", + "strikethrough": "Tachado", + "trash": "Lixeira", + "underline": "Sublinhado", + "undoSend": "Enviando em {seconds}s", + "undoSendAction": "Desfazer", + "unreadCount": "{count} não lidas", + "unreadFilter": "Não lidas" + }, + "settings": { + "actionDiscard": "Descartar", + "actionFlag": "Marcar mensagem", + "actionMove": "Mover para o Arquivo", + "addRule": "Adicionar regra", + "appPasswordPlaceholder": "Thunderbird / Outlook Mobile", + "appPasswords": "Senhas de aplicativo", + "autoReplySaveFailed": "Não foi possível salvar a resposta automática", + "enableMfa": "Ativar verificação em duas etapas", + "fieldFrom": "De", + "fieldHeader": "Cabeçalho", + "fieldSubject": "Assunto", + "generateAppPassword": "Gerar", + "langEn": "English", + "langEs": "Español", + "langPtBR": "Português (Brasil)", + "language": "Idioma", + "matchContains": "contém", + "matchEquals": "é exatamente", + "matchRegex": "corresponde à regex", + "mfaDisabled": "Desativada", + "mfaEnabled": "Ativada", + "mfaStatus": "Status da verificação em duas etapas", + "recoveryCodesTitle": "Códigos de recuperação", + "ruleIf": "SE", + "ruleThen": "ENTÃO", + "saveAutoReply": "Salvar resposta automática", + "saveRecoveryCodes": "Guarde-os em local seguro. Cada um funciona uma única vez e eles só aparecem agora.", + "security": "Segurança", + "sessions": "Sessões", + "settingsSaved": "Configurações salvas.", + "sieveIntro": "Regras que organizam as mensagens recebidas automaticamente.", + "sieveTitle": "Filtros (Sieve)", + "signOut": "Sair", + "signatureIntro": "Anexada automaticamente às mensagens que você escreve.", + "signaturePlaceholder": "Atenciosamente,\nSeu nome\nSeu cargo", + "signatureSaveFailed": "Não foi possível salvar a assinatura", + "signatureSaved": "Assinatura salva.", + "signatureTitle": "Assinatura de e-mail", + "sixDigitCode": "Código de seis dígitos", + "subtitle": "Verificação em duas etapas, filtros, resposta automática e sua assinatura.", + "title": "Configurações", + "twoFactorIntro": "Proteja sua conta com o Google Authenticator, Aegis, Authy ou 1Password.", + "vacationBodyLabel": "Mensagem da resposta", + "vacationDefaultBody": "Obrigado pela sua mensagem. Estou ausente no momento e responderei assim que retornar.", + "vacationDefaultSubject": "Fora do escritório", + "vacationIntro": "Responde automaticamente às mensagens recebidas enquanto você estiver ausente.", + "vacationSubjectLabel": "Assunto da resposta", + "vacationTitle": "Resposta automática de ausência", + "valuePlaceholder": "Valor a corresponder..." }, "ui": { - "accountStatus": "Situacao da conta", - "actions": "Acoes", - "active": "ATIVA", - "activeSessions": "Sessoes ativas do webmail", - "actor": "Autor / conta", - "actualRecord": "Registro DNS atual (Cloudflare)", - "allChecksPassed": "TODAS AS CHECAGENS PASSARAM", - "appPasswordGenerated": "Senha de aplicativo gerada:", - "appPasswordsSection": "Senhas de aplicativo (clientes IMAP / SMTP)", + "accountStatus": "Status da conta", + "actions": "Ações", + "active": "Ativo", + "activeSessions": "Sessões ativas", + "actor": "Autor", + "actualRecord": "Valor atual", + "allChecksPassed": "Todas as verificações passaram", + "appPasswordGenerated": "Senha de aplicativo gerada", + "appPasswordsSection": "Senhas de aplicativo", "attempts": "Tentativas", + "bounceRate": "Taxa de devolução", + "changePassword": "Alterar senha", "clientIp": "IP do cliente", - "copyPasswordNow": "Copie esta senha agora. Ela nao sera exibida novamente.", - "coreInfra": "Estado da infraestrutura", - "currentSession": "Sessao atual deste navegador", - "daneVerified": "DANE / MTA-STS verificado", + "copyPasswordNow": "Copie agora. Ela não será exibida novamente.", + "coreInfra": "Serviços principais", + "currentPassword": "Senha atual", + "currentSession": "Este dispositivo", + "daneVerified": "DANE verificado", "destination": "Destino", - "diskUsed": "Uso de disco", - "dkimResult": "Resultado DKIM", - "dkimAlignment": "Alinhamento da assinatura DKIM", - "dmarcEnforcement": "Aplicacao da politica DMARC", - "domainVerification": "Estado da verificacao do dominio", - "emailAddress": "Endereco de e-mail", - "eventAction": "Acao registrada", - "expectedValue": "Valor esperado pela especificacao", - "healthy": "SAUDAVEL", - "imapEngine": "Servidor IMAP (143/993)", - "inboundVolume": "Volume de entrada (24h)", - "jobId": "ID do trabalho", - "lastError": "Ultimo erro", - "liveMetrics": "Metricas operacionais do no de e-mail.", - "localDisk": "Armazenamento em disco local", - "preflightTitle": "Diagnostico de preflight do servidor", - "messageBody": "Corpo da mensagem", - "messageVolume": "Volume de mensagens", - "noAppPasswords": "Nenhuma senha de aplicativo gerada ainda.", - "online": "ONLINE", - "outboundDelivered": "Entregues na saida (24h)", - "queueDepth": "Profundidade da fila", - "postgresRunner": "Executor de tarefas Postgres ativo", - "provisionMailbox": "Criar nova caixa postal", - "recipient": "Destinatario", - "recordName": "Nome do registro", - "reportingOrg": "Organizacao que reportou", - "scanners": "Rspamd (antispam) e ClamAV", + "diskUsed": "Armazenamento usado", + "dkimAlignment": "Alinhamento DKIM", + "dkimResult": "DKIM", + "dmarcEnforcement": "Política DMARC", + "domainVerification": "Verificação de domínio", + "domainsVerified": "Domínios verificados", + "emailAddress": "Endereço de e-mail", + "eventAction": "Ação", + "expectedValue": "Valor esperado", + "healthy": "Saudável", + "imapEngine": "Serviço IMAP", + "inboundVolume": "Recebidas (24h)", + "jobId": "Trabalho", + "lastError": "Último erro", + "liveMetrics": "Métricas ao vivo", + "localDisk": "Disco local", + "mailboxesActive": "Caixas ativas", + "messageBody": "Mensagem", + "messageVolume": "Mensagens", + "newPassword": "Nova senha", + "noAppPasswords": "Nenhuma senha de aplicativo ainda.", + "noData": "Ainda sem dados", + "online": "Online", + "outboundDelivered": "Enviadas (24h)", + "passwordChanged": "Senha alterada.", + "postgresRunner": "Fila baseada em Postgres", + "preflightTitle": "Prontidão", + "provisionMailbox": "Criar caixa de e-mail", + "queueDepth": "Tamanho da fila", + "recipient": "Destinatário", + "recordName": "Registro", + "reportingOrg": "Organização que reportou", + "scanners": "Análise de spam e vírus", "sender": "Remetente", - "smtpListener": "Servidor SMTP (25/587/465)", + "signOutOthers": "Sair dos outros dispositivos", + "smtpListener": "Serviço SMTP", "sourceIp": "IP de origem", - "spfRatio": "Taxa de alinhamento SPF", - "spfResult": "Resultado SPF", + "spfRatio": "Taxa de aprovação SPF", + "spfResult": "SPF", "storageQuota": "Cota de armazenamento", "subject": "Assunto", - "targetObject": "Objeto alvo", - "timestamp": "Data e hora", - "toRecipients": "Para (destinatarios)", - "totalEvaluated": "Total de mensagens avaliadas", - "twoFactorSection": "Autenticacao em dois fatores (TOTP RFC 6238)", - "watching": "OBSERVANDO", - "passwordChanged": "Senha alterada", - "signOutOthers": "Encerrar as outras sessoes", - "changePassword": "Alterar senha", - "currentPassword": "Senha atual", - "newPassword": "Nova senha (min. 12 caracteres)", - "toggleMenu": "Abrir ou fechar o menu" + "targetObject": "Alvo", + "timestamp": "Horário", + "toRecipients": "Para", + "toggleMenu": "Alternar menu", + "totalEvaluated": "Mensagens avaliadas", + "twoFactorSection": "Verificação em duas etapas", + "watching": "Monitorando" } } diff --git a/services/webmail/package-lock.json b/services/webmail/package-lock.json index 9837f42..73087ea 100644 --- a/services/webmail/package-lock.json +++ b/services/webmail/package-lock.json @@ -13,6 +13,7 @@ "framer-motion": "^11.18.2", "lucide-react": "^0.474.0", "next": "15.5.21", + "qrcode-generator": "^2.0.4", "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^3.0.1" @@ -3277,6 +3278,12 @@ "node": ">=6" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/services/webmail/package.json b/services/webmail/package.json index b201e64..c046885 100644 --- a/services/webmail/package.json +++ b/services/webmail/package.json @@ -15,6 +15,7 @@ "framer-motion": "^11.18.2", "lucide-react": "^0.474.0", "next": "15.5.21", + "qrcode-generator": "^2.0.4", "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^3.0.1" diff --git a/services/webmail/src/app/(admin)/admin/dashboard/page.tsx b/services/webmail/src/app/(admin)/admin/dashboard/page.tsx index e201a7c..c15f8dc 100644 --- a/services/webmail/src/app/(admin)/admin/dashboard/page.tsx +++ b/services/webmail/src/app/(admin)/admin/dashboard/page.tsx @@ -19,16 +19,24 @@ import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card"; import { Badge } from "../../../../components/ui/Badge"; import { Button } from "../../../../components/ui/Button"; +/** Exactly the fields /api/v1/admin/dashboard returns - see dashboardStats(). */ interface DashboardMetrics { inbound_24h: number; outbound_24h: number; queue_depth: number; bounce_rate: number; - spam_score_avg: number; - disk_used_percent: number; domains_verified: number; domains_total: number; - preflight_status: string; + mailboxes_active: number; + storage_used_bytes: number; + storage_quota_bytes: number; +} + +function formatBytes(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } export default function AdminDashboardPage() { @@ -39,9 +47,9 @@ export default function AdminDashboardPage() { const fetchMetrics = () => { setLoading(true); fetch("/api/v1/admin/dashboard") - .then((res) => res.json()) + .then((res) => (res.ok ? res.json() : null)) .then((data) => setMetrics(data)) - .catch(() => {}) + .catch(() => setMetrics(null)) .finally(() => setLoading(false)); }; @@ -49,18 +57,31 @@ export default function AdminDashboardPage() { fetchMetrics(); }, []); + /** + * A missing figure reads as "no data", never as a number. + * + * Each of these used to fall back to an invented constant - 1420 received, + * 890 sent, 3 queued, 18.4% disk - so an unreachable API produced a dashboard + * of plausible traffic for a server that might not have delivered a single + * message. Two of those fields (disk_used_percent, spam_score_avg) were never + * returned by the endpoint at all, so the fake disk figure was what everyone + * always saw. + */ + const shown = (value: number | undefined, format?: (n: number) => string) => + value === undefined || value === null ? t("ui.noData") : format ? format(value) : String(value); + const kpis = [ { title: t("ui.inboundVolume"), - value: metrics?.inbound_24h ?? 1420, - subtext: "100% SPF/DKIM validado", + value: shown(metrics?.inbound_24h), + subtext: t("ui.messageVolume"), icon: Inbox, color: "text-indigo-400", bg: "bg-indigo-500/10", }, { title: t("ui.outboundDelivered"), - value: metrics?.outbound_24h ?? 890, + value: shown(metrics?.outbound_24h), subtext: t("ui.daneVerified"), icon: Send, color: "text-emerald-400", @@ -68,7 +89,7 @@ export default function AdminDashboardPage() { }, { title: t("ui.queueDepth"), - value: metrics?.queue_depth ?? 3, + value: shown(metrics?.queue_depth), subtext: t("ui.postgresRunner"), icon: Layers, color: "text-cyan-400", @@ -76,8 +97,10 @@ export default function AdminDashboardPage() { }, { title: t("ui.diskUsed"), - value: `${metrics?.disk_used_percent ?? 18.4}%`, - subtext: t("ui.localDisk"), + value: shown(metrics?.storage_used_bytes, formatBytes), + subtext: metrics + ? `${t("ui.storageQuota")}: ${formatBytes(metrics.storage_quota_bytes)}` + : t("ui.localDisk"), icon: HardDrive, color: "text-amber-400", bg: "bg-amber-500/10", @@ -89,23 +112,26 @@ export default function AdminDashboardPage() { {/* Header Bar */}
-

+

{t("admin.dashboardTitle")} - - v1.2 Live -

-

{t("ui.liveMetrics")}

+

{t("admin.dashboardSubtitle")}

- - - PREFLIGHT: {metrics?.preflight_status || "HEALTHY"} - + {/* Reports the count it can actually see. The old badge asserted + "PREFLIGHT: HEALTHY" from a field the endpoint never sends, so it + said healthy unconditionally. The real checks live on the + security screen. */} + {metrics && ( + + + {t("ui.domainsVerified")}: {metrics.domains_verified}/{metrics.domains_total} + + )}
@@ -141,68 +167,58 @@ export default function AdminDashboardPage() { })} - {/* Security & System Infra */} -
- {/* Core Infrastructure Health */} - - - - - {t("ui.coreInfra")} - - 4/4 Ativos - -
- {[ - { name: t("ui.smtpListener"), status: t("ui.online"), port: "Porta 25 / 587" }, - { name: t("ui.imapEngine"), status: t("ui.online"), port: "Porta 993 (SSL)" }, - { name: t("ui.scanners"), status: t("ui.healthy"), port: "Rspamd Engine" }, - { name: "Traefik Acme Watcher", status: t("ui.watching"), port: "fsnotify + poll" }, - ].map((svc, i) => ( -
-
-
-
- {svc.name} - {svc.port} -
-
- {svc.status} -
- ))} -
- + {/* + A single card of figures that are genuinely queried. - {/* Domain Reconciliation */} - - - - - {t("ui.domainVerification")} - - Auto-Reconciled - -
-
-
- - example.com + What stood here were two cards of decoration: a "core services" panel + that listed SMTP, IMAP, Rspamd and the cert watcher with a hardcoded + "Online"/"4/4 active" and a pulsing green dot - nothing was ever polled, + so it stayed green through any outage - and a domain panel pinned to + "example.com" with "13 RECORDS VERIFIED" and a "last checked: just now" + that was a literal string. Container liveness is now covered by the + compose healthchecks, and the per-domain DNS state lives on the domains + screen, which reads it from the database. + */} + + + + + {t("ui.domainVerification")} + + + {metrics ? ( +
+
+
+ + {t("ui.domainsVerified")} +
+
+ {metrics.domains_verified} + / {metrics.domains_total}
- 13 RECORDS VERIFIED
-

- Cloudflare DNS automation active. SPF, DKIM (RSA 2048 + Ed25519), DMARC (quarantine), MTA-STS e TLS-RPT totalmente sincronizados e validados. -

-
- Last checked: just now - Verificar Registros → +
+
+ + {t("ui.mailboxesActive")} +
+
{metrics.mailboxes_active}
+
+
+
+ + {t("ui.bounceRate")} +
+
+ {(metrics.bounce_rate * 100).toFixed(1)}% +
- -
+ ) : ( +
{t("ui.noData")}
+ )} +
); } diff --git a/services/webmail/src/app/(admin)/admin/dmarc/page.tsx b/services/webmail/src/app/(admin)/admin/dmarc/page.tsx index cb2700f..2ba09f6 100644 --- a/services/webmail/src/app/(admin)/admin/dmarc/page.tsx +++ b/services/webmail/src/app/(admin)/admin/dmarc/page.tsx @@ -25,7 +25,7 @@ export default function AdminDmarcPage() { useEffect(() => { fetch("/api/v1/admin/dmarc") - .then((res) => res.json()) + .then((res) => (res.ok ? res.json() : null)) .then((d) => setData(d)) .catch(() => {}); }, []); @@ -34,27 +34,27 @@ export default function AdminDmarcPage() {

{t("admin.dmarcTitle")}

-

Ingested XML aggregate reports breakdown (dmarc_reports table).

+

{t("admin.dmarcSubtitle")}

{/* DMARC Stats Summary Grid */}
{t("ui.totalEvaluated")}
-
{data?.total_messages ?? 1250}
+
{data ? data.total_messages : t("ui.noData")}
{t("ui.spfRatio")}
- {data ? Math.round((data.spf_pass_count / data.total_messages) * 100) : 98}% + {data && data.total_messages > 0 ? `${Math.round((data.spf_pass_count / data.total_messages) * 100)}%` : t("ui.noData")}
{t("ui.dkimAlignment")}
- {data ? Math.round((data.dkim_pass_count / data.total_messages) * 100) : 97}% + {data && data.total_messages > 0 ? `${Math.round((data.dkim_pass_count / data.total_messages) * 100)}%` : t("ui.noData")}
@@ -67,7 +67,7 @@ export default function AdminDmarcPage() { {/* Sending Sources Table */}
- Inbound Aggregate Reporting Sending IP Sources + {t("ui.sourceIp")}
diff --git a/services/webmail/src/app/(admin)/admin/domains/page.tsx b/services/webmail/src/app/(admin)/admin/domains/page.tsx index 441e424..7e47d21 100644 --- a/services/webmail/src/app/(admin)/admin/domains/page.tsx +++ b/services/webmail/src/app/(admin)/admin/domains/page.tsx @@ -1,445 +1,360 @@ "use client"; -import React, { useState } from "react"; -import { - Globe, - RefreshCw, - Plus, - KeyRound, - ShieldCheck, - CheckCircle2, - ArrowRight, - Copy, - Layers, - Sparkles, -} from "lucide-react"; +import React, { useCallback, useEffect, useState } from "react"; +import { Globe, RefreshCw, Plus, KeyRound, CheckCircle2, AlertTriangle } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; -import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card"; +import { Card } from "../../../../components/ui/Card"; import { Badge } from "../../../../components/ui/Badge"; import { Button } from "../../../../components/ui/Button"; -interface DnsRecord { - type: string; +/** + * Domains, read from the database. + * + * The previous version of this screen was a mockup end to end. It rendered a + * fixed table of thirteen DNS records for "example.com", every one of them + * marked VERIFIED; its onboarding wizard had four steps of which only the first + * called anything, step three printing "Cloudflare connection validated, 13 + * records created and propagated" without a request behind it; and its DKIM + * rotation posted {domain_id: "default-domain"} to an endpoint whose field is + * "domain", swallowed the resulting 400, and displayed a made-up TXT record as + * though a key had been issued. Rotation therefore never once worked from here. + * + * What is shown now is what the API returns, and the one thing the backend + * cannot yet do - verify individual DNS records against a resolver - says so + * instead of drawing a table of green ticks. + */ + +interface Domain { + id: string; name: string; - expectedValue: string; - actualValue: string; - status: "VERIFIED" | "DRIFT" | "PARTIAL"; + dns_status: string; + dmarc_policy: string | null; + mta_sts_mode: string | null; + dane_enabled: boolean; + is_active: boolean; + dns_last_checked_at: string | null; + mailbox_count: number; } -const DNS_RECORDS_13: DnsRecord[] = [ - { type: "MX", name: "@", expectedValue: "10 mail.example.com", actualValue: "10 mail.example.com", status: "VERIFIED" }, - { type: "TXT", name: "@", expectedValue: "v=spf1 mx ~all", actualValue: "v=spf1 mx ~all", status: "VERIFIED" }, - { type: "TXT", name: "default._domainkey", expectedValue: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN...", actualValue: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN...", status: "VERIFIED" }, - { type: "TXT", name: "ed25519._domainkey", expectedValue: "v=DKIM1; k=ed25519; p=117p2...==", actualValue: "v=DKIM1; k=ed25519; p=117p2...==", status: "VERIFIED" }, - { type: "TXT", name: "_dmarc", expectedValue: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", actualValue: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", status: "VERIFIED" }, - { type: "TXT", name: "_mta-sts", expectedValue: "v=STSv1; id=20260802", actualValue: "v=STSv1; id=20260802", status: "VERIFIED" }, - { type: "CNAME", name: "mta-sts", expectedValue: "mail.example.com", actualValue: "mail.example.com", status: "VERIFIED" }, - { type: "TXT", name: "_smtp._tlsa", expectedValue: "3 1 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", actualValue: "3 1 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", status: "VERIFIED" }, - { type: "TXT", name: "_smtp._tls", expectedValue: "v=TLSRPTv1; rua=mailto:tlsrpt@example.com", actualValue: "v=TLSRPTv1; rua=mailto:tlsrpt@example.com", status: "VERIFIED" }, - { type: "SRV", name: "_autodiscover._tcp", expectedValue: "0 0 443 mail.example.com", actualValue: "0 0 443 mail.example.com", status: "VERIFIED" }, - { type: "SRV", name: "_submission._tcp", expectedValue: "0 0 587 mail.example.com", actualValue: "0 0 587 mail.example.com", status: "VERIFIED" }, - { type: "SRV", name: "_imaps._tcp", expectedValue: "0 0 993 mail.example.com", actualValue: "0 0 993 mail.example.com", status: "VERIFIED" }, - { type: "A", name: "mail", expectedValue: "203.0.113.195", actualValue: "203.0.113.195", status: "VERIFIED" }, -]; +function statusVariant(status: string): "success" | "warning" | "danger" { + if (status === "VERIFIED") return "success"; + if (status === "PENDING" || status === "PARTIAL") return "warning"; + return "danger"; +} export default function AdminDomainsPage() { const t = useTranslations(); - const [activeTab, setActiveTab] = useState<"dns" | "onboarding" | "dkim">("dns"); + const [activeTab, setActiveTab] = useState<"domains" | "onboarding" | "dkim">("domains"); + const [domains, setDomains] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [loading, setLoading] = useState(true); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + const [reconciling, setReconciling] = useState(false); - const [syncMessage, setSyncMessage] = useState(null); - - // Guided Onboarding Wizard state - const [onboardStep, setOnboardStep] = useState(1); const [onboardDomain, setOnboardDomain] = useState(""); - const [onboardAdminEmail, setOnboardAdminEmail] = useState(""); - const [onboardSuccess, setOnboardSuccess] = useState(false); + const [onboarding, setOnboarding] = useState(false); - // DKIM Rotation Wizard state - const [dkimKeyType, setDkimKeyType] = useState<"Ed25519" | "RSA-2048">("Ed25519"); - const [dkimSelector, setDkimSelector] = useState("s202608"); - const [dkimRotationStep, setDkimRotationStep] = useState<"idle" | "generated" | "active">("idle"); - const [generatedDkimTxt, setGeneratedDkimTxt] = useState(null); + const [dkimAlgorithm, setDkimAlgorithm] = useState<"ed25519" | "rsa2048">("ed25519"); + const [dkimSelector, setDkimSelector] = useState("s1"); + const [dkimRecord, setDkimRecord] = useState<{ name: string; value: string; overlapDays: number } | null>(null); + const [rotating, setRotating] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/v1/admin/domains"); + if (!res.ok) throw new Error(); + const data: Domain[] = await res.json(); + setDomains(data); + // Keeps the current pick if it still exists, so a refresh after an action + // does not silently retarget the next operation at another domain. + setSelectedId((prev) => (data.some((d) => d.id === prev) ? prev : (data[0]?.id ?? ""))); + setError(null); + } catch { + setDomains([]); + setError(t("errors.loadFailed")); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + const selected = domains.find((d) => d.id === selectedId) ?? null; const handleReconcile = async () => { + if (!selected) return; setReconciling(true); - setSyncMessage(null); + setNotice(null); + setError(null); try { + // The real domain id, not the literal string "default-domain" that used + // to be sent here for every domain on the server. const res = await fetch("/api/v1/admin/domains/reconcile", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ domain_id: "default-domain" }), + body: JSON.stringify({ domain_id: selected.id }), }); - if (res.ok) { - setSyncMessage("Cloudflare sync finished: all 13 DNS records verified."); - } else { - setSyncMessage("DNS reconciliation requested."); - } - } catch { - setSyncMessage("Could not reach the reconciliation endpoint."); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.message); + setNotice(data.message ?? t("settings.settingsSaved")); + await load(); + } catch (err) { + setError(err instanceof Error && err.message ? err.message : t("errors.serverError")); } finally { setReconciling(false); } }; - const handleStartDkimRotation = async () => { + const handleOnboard = async (e: React.FormEvent) => { + e.preventDefault(); + if (!onboardDomain.trim()) return; + setOnboarding(true); + setNotice(null); + setError(null); try { - await fetch("/api/v1/admin/dkim/rotate", { + const res = await fetch("/api/v1/admin/domains/onboard", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ domain: onboardDomain.trim() }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.message); + setOnboardDomain(""); + setNotice(`${data.name}`); + await load(); + setActiveTab("domains"); + } catch (err) { + setError(err instanceof Error && err.message ? err.message : t("errors.serverError")); + } finally { + setOnboarding(false); + } + }; + + const handleRotateDkim = async () => { + if (!selected) return; + setRotating(true); + setNotice(null); + setError(null); + setDkimRecord(null); + try { + // The endpoint's field is "domain" and it wants the name. Sending the + // wrong field is what made every previous rotation fail unnoticed. + const res = await fetch("/api/v1/admin/dkim/rotate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - domain_id: "default-domain", + domain: selected.name, selector: dkimSelector, - algorithm: dkimKeyType.toLowerCase().includes("ed") ? "ed25519" : "rsa2048", + algorithm: dkimAlgorithm, }), }); - } catch { - // fallback handling + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.message); + // The key that was actually generated and sealed, not a sample string. + setDkimRecord({ + name: `${data.selector}._domainkey.${data.domain}`, + value: data.public_key_record, + overlapDays: data.overlap_days, + }); + } catch (err) { + setError(err instanceof Error && err.message ? err.message : t("errors.serverError")); + } finally { + setRotating(false); } - setDkimRotationStep("generated"); - setGeneratedDkimTxt(`v=DKIM1; k=${dkimKeyType.toLowerCase().includes("ed") ? "ed25519" : "rsa"}; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC3...`); }; - const handleActivateDkim = () => { - setDkimRotationStep("active"); - }; - - const handleNextOnboardStep = async (e: React.FormEvent) => { - e.preventDefault(); - if (onboardStep === 1 && !onboardDomain) return; - if (onboardStep === 1) { - try { - await fetch("/api/v1/admin/domains/onboard", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ domain: onboardDomain }), - }); - } catch { - // fallback - } - } - if (onboardStep < 4) { - setOnboardStep(onboardStep + 1); - } else { - setOnboardSuccess(true); - } - }; + const tabs = [ + { id: "domains" as const, label: t("admin.domainsTitle"), icon: Globe }, + { id: "onboarding" as const, label: t("common.add"), icon: Plus }, + { id: "dkim" as const, label: "DKIM", icon: KeyRound }, + ]; return ( -
- {/* Header & Tabs */} +
-

+

{t("admin.domainsTitle")}

-

- DNS reconciliation, guided domain onboarding and signing key rotation DKIM. -

- {/* Tab Selection Controls */}
- - - - - + {tabs.map((tab) => { + const Icon = tab.icon; + return ( + + ); + })}
- {/* TAB 1: 13 DNS RECORDS RECONCILIATION */} - {activeTab === "dns" && ( -
-
- -
-
- -
-
-
example.com
-
Primary domain | DMARC: quarantine | MTA-STS: testing
-
-
- 13 / 13 VERIFICADOS -
+ {notice && ( +
+ + {notice} +
+ )} + {error && ( +
+ {error} +
+ )} - -
- - {syncMessage && ( -
- - {syncMessage} -
+ {activeTab === "domains" && ( +
+ {domains.length === 0 && !loading ? ( + {t("admin.noDomains")} + ) : ( + domains.map((d) => ( + +
+
+
+ +
+
+
{d.name}
+
+ {t("admin.mailboxCount", { count: d.mailbox_count })} + {d.dmarc_policy ? ` | DMARC: ${d.dmarc_policy}` : ""} + {d.mta_sts_mode ? ` | MTA-STS: ${d.mta_sts_mode}` : ""} + {d.dane_enabled ? " | DANE" : ""} +
+
+
+
+ {d.dns_status} + +
+
+
+ {t("admin.lastChecked", { + when: d.dns_last_checked_at + ? new Date(d.dns_last_checked_at).toLocaleString() + : t("common.never"), + })} +
+
+ )) )} - -
- Expected 13 DNS records versus actual state -
- -
- - - - - - - - - - - - {DNS_RECORDS_13.map((rec, idx) => ( - - - - - - - - ))} - -
Tipo{t("ui.recordName")}{t("ui.expectedValue")}{t("ui.actualRecord")}Status
{rec.type}{rec.name}{rec.expectedValue}{rec.actualValue} - {rec.status} -
+ {/* Says what reconciliation currently does. The old copy announced + "Cloudflare sync finished: all 13 DNS records verified" on every + press - including when the request failed - while the endpoint + behind it only writes an audit entry. */} + +
+ +

{t("admin.reconcileNotImplemented")}

+
)} - {/* TAB 2: GUIDED DOMAIN ONBOARDING WIZARD */} {activeTab === "onboarding" && ( - +
-

- - Guided domain onboarding -

-

- Step by step provisioning for new domains and their Cloudflare records DNS. -

+

{t("admin.onboardTitle")}

+

{t("admin.onboardIntro")}

- - {/* Stepper Header */} -
- {["1. Domain", "2. DNS records", "3. Verification", "4. Aliases"].map((label, idx) => ( -
idx + 1 - ? "bg-slate-900 border-slate-700 text-slate-400" - : "bg-slate-950 border-slate-800 text-slate-600" - }`} - > - {label} -
- ))} -
- - {onboardSuccess ? ( -
-
- - Domain onboarding complete -
-

- The domain {onboardDomain} was provisioned with the 13 DNS records and the RFC 2142 aliases. -

+
+
+ + setOnboardDomain(e.target.value)} + placeholder="example.org" + required + className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" + />
- ) : ( - - {onboardStep === 1 && ( -
-
- - setOnboardDomain(e.target.value)} - placeholder="novafirma.com" - required - className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" - /> -
-
- - setOnboardAdminEmail(e.target.value)} - placeholder="admin@novafirma.com" - required - className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" - /> -
-
- )} - - {onboardStep === 2 && ( -
-
The 13 DNS records generated:
-
-                    MX 10 mail.{onboardDomain}

-                    TXT v=spf1 mx ~all

-                    TXT default._domainkey IN TXT "v=DKIM1; k=rsa; p=..."

-                    TXT ed25519._domainkey IN TXT "v=DKIM1; k=ed25519; p=..."

-                    TXT _dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@{onboardDomain}"
-                  
-
- )} - - {onboardStep === 3 && ( -
-
Verificando Registros via API Cloudflare...
-
- - Cloudflare connection validated. 13 records created and propagated. -
-
- )} - - {onboardStep === 4 && ( -
-
Provisioning the standard aliases (RFC 2142):
-
    -
  • postmaster@{onboardDomain} → {onboardAdminEmail}
  • -
  • abuse@{onboardDomain} → {onboardAdminEmail}
  • -
  • security@{onboardDomain} → {onboardAdminEmail}
  • -
-
- )} - -
- - - -
-
- )} + + )} - {/* TAB 3: GUIDED DKIM KEY ROTATION WIZARD */} {activeTab === "dkim" && ( - +

- DKIM key rotation + {t("admin.dkimTitle")}

- Replace a domain signing key without interrupting deliverability. + {selected ? selected.name : t("admin.noDomains")}

- +
-
- + setDkimSelector(e.target.value)} + pattern="[A-Za-z0-9-]{1,63}" className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono focus:outline-none focus:border-emerald-500" />
- {dkimRotationStep === "idle" && ( - - )} + - {dkimRotationStep === "generated" && generatedDkimTxt && ( -
-
1. Adicione o Novo Registro de Seletor DKIM no DNS:
+ {dkimRecord && ( +
+
{t("admin.dkimPublishRecord")}
- {dkimSelector}._domainkey.example.com IN TXT "{generatedDkimTxt}" + {dkimRecord.name} IN TXT "{dkimRecord.value}"
-
- Dual-signature overlap active (7 days) -

Both keys sign outbound mail so nothing fails while DNS propagates.

+ {t("admin.dkimOverlap", { days: dkimRecord.overlapDays })}
- - -
- )} - - {dkimRotationStep === "active" && ( -
- - DKIM key rotation complete. The selector "{dkimSelector}" is active.
)} diff --git a/services/webmail/src/app/(admin)/admin/layout.tsx b/services/webmail/src/app/(admin)/admin/layout.tsx index 155d8d9..223c322 100644 --- a/services/webmail/src/app/(admin)/admin/layout.tsx +++ b/services/webmail/src/app/(admin)/admin/layout.tsx @@ -16,14 +16,17 @@ import { Menu, X, Lock, + Mail, } from "lucide-react"; import { useTranslations } from "../../../i18n/provider"; import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher"; +import { useAccount } from "../../../lib/useAccount"; export default function AdminLayout({ children }: { children: React.ReactNode }) { const t = useTranslations(); const pathname = usePathname(); const [mobileOpen, setMobileOpen] = useState(false); + const account = useAccount("admin"); const navItems = [ { href: "/admin/dashboard", label: t("admin.dashboardTitle"), icon: LayoutDashboard }, @@ -56,14 +59,16 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
- {/* Security Badge Indicator */} -
- -
- Isolated Surface - aud: lambdamail:admin -
-
+ {/* Leaving the console needs no ceremony - the webmail session was + never given up to get here, so this is a plain link back. Coming the + other way is what costs a second factor. */} + + + {t("admin.backToWebmail")} + {/* Admin Navigation */}
@@ -293,10 +292,10 @@ export default function AdminMailboxesPage() {

- Two-factor requirement policy + {t("admin.mfaPolicyTitle")}

- Choose how strictly this domain requires a second factor. + {t("admin.mfaPolicyIntro")}

@@ -309,7 +308,7 @@ export default function AdminMailboxesPage() { : "border-slate-800 text-slate-400 hover:text-slate-200" }`} > - Opcional + {t("admin.mfaOptional")}
@@ -358,7 +357,7 @@ export default function AdminMailboxesPage() { type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} - placeholder="Initial password (at least 12 characters)" + placeholder={t("ui.newPassword")} minLength={12} required className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" @@ -376,11 +375,11 @@ export default function AdminMailboxesPage() { type="number" value={newQuota} onChange={(e) => setNewQuota(parseInt(e.target.value, 10))} - placeholder="Cota (MB)" + placeholder={t("ui.storageQuota")} className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-emerald-500" /> @@ -395,11 +394,11 @@ export default function AdminMailboxesPage() { {t("ui.emailAddress")} - Role - {t("ui.storageQuota")} - Status 2FA {t("ui.accountStatus")} - Actions + {t("ui.storageQuota")} + {t("ui.twoFactorSection")} + {t("ui.accountStatus")} + {t("ui.actions")} @@ -465,7 +464,7 @@ export default function AdminMailboxesPage() { - Criar Alias de Roteamento + {t("admin.createAlias")}
@@ -486,7 +485,7 @@ export default function AdminMailboxesPage() { className="flex-1 px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" />
@@ -500,10 +499,10 @@ export default function AdminMailboxesPage() { - - - - + + + + @@ -563,7 +562,7 @@ export default function AdminMailboxesPage() {
{csvPreview.length > 0 && ( diff --git a/services/webmail/src/app/(admin)/admin/queue/page.tsx b/services/webmail/src/app/(admin)/admin/queue/page.tsx index 718a7a3..3c8eda1 100644 --- a/services/webmail/src/app/(admin)/admin/queue/page.tsx +++ b/services/webmail/src/app/(admin)/admin/queue/page.tsx @@ -1,104 +1,201 @@ "use client"; -import React, { useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; +import { RefreshCw } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; +/** + * The outbound queue, read from the database. + * + * This screen used to be a fixture: two invented jobs ("job-101", + * user@example.com -> partner@remotehost.com) held in component state, whose + * Retry and Freeze buttons only edited that local array. It showed the same two + * rows on every deployment, never displayed a real stuck message, and reported + * success for actions that had not happened. /api/v1/admin/queue was already + * serving the real thing. + */ + +/** Mirrors the outbound_jobs row shape returned by the queue endpoint. */ interface QueueJob { id: string; - sender: string; - recipient: string; - destinationDomain: string; - attempts: number; - lastError: string; - status: "QUEUED" | "RETRIABLE" | "FROZEN"; + envelope_from: string; + envelope_to: string; + destination_domain: string; + status: string; + attempt: number; + next_attempt_at: string | null; + last_smtp_code: number | null; + last_error: string | null; } -const INITIAL_JOBS: QueueJob[] = [ - { id: "job-101", sender: "user@example.com", recipient: "partner@remotehost.com", destinationDomain: "remotehost.com", attempts: 1, lastError: "Connection timeout to 198.51.100.25:25", status: "RETRIABLE" }, - { id: "job-102", sender: "admin@example.com", recipient: "alerts@monitoring.org", destinationDomain: "monitoring.org", attempts: 0, lastError: "None", status: "QUEUED" }, -]; +interface QueueSummary { + by_status: Record; + recent: QueueJob[]; +} + +/** Only failure states earn a warning colour; the rest are ordinary progress. */ +function statusClass(status: string): string { + if (status === "BOUNCED" || status === "FROZEN") return "badge-danger"; + if (status === "DEFERRED") return "badge-warning"; + return "badge-verified"; +} export default function AdminQueuePage() { const t = useTranslations(); - const [jobs, setJobs] = useState(INITIAL_JOBS); + const [summary, setSummary] = useState(null); const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); - const handleRetry = (id: string) => { - setJobs(jobs.map((j) => (j.id === id ? { ...j, status: "QUEUED", attempts: j.attempts + 1 } : j))); - setMessage(`Job ${id} forced for immediate retry attempt.`); + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/v1/admin/queue"); + if (!res.ok) throw new Error(); + setSummary(await res.json()); + setError(null); + } catch { + // Left null so the table renders empty rather than showing stale or + // invented rows while the service is unreachable. + setSummary(null); + setError(t("errors.loadFailed")); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + /** + * Retry and cancel are the two actions the service actually implements. + * The old "Freeze" button named a third that has never existed anywhere in + * the backend, so pressing it changed nothing but the colour of a cell. + */ + const act = async (id: string, action: "retry" | "cancel") => { + setMessage(null); + setError(null); + const res = await fetch(`/api/v1/admin/queue/${encodeURIComponent(id)}/${action}`, { + method: "POST", + }); + if (!res.ok) { + setError(t("errors.serverError")); + return; + } + setMessage(t(action === "retry" ? "admin.jobRetried" : "admin.jobCancelled", { id })); + // Re-read rather than patching local state, so what is on screen is what + // the queue now holds. + await load(); }; - const handleFreeze = (domain: string) => { - setJobs(jobs.map((j) => (j.destinationDomain === domain ? { ...j, status: "FROZEN" } : j))); - setMessage(`Delivery destination domain '${domain}' has been frozen.`); - }; + const jobs = summary?.recent ?? []; return ( -
-
-

{t("admin.queueTitle")}

-

Inspect outbound queue, retry retriable jobs, or freeze problematic destinations.

+
+
+
+

{t("admin.queueTitle")}

+

{t("admin.queueSubtitle")}

+
+
+ {/* Counts per status, straight from the by_status aggregate. */} + {summary && Object.keys(summary.by_status).length > 0 && ( +
+ {Object.entries(summary.by_status).map(([status, count]) => ( + + {count} {status} + + ))} +
+ )} + {message && (
{message}
)} + {error && ( +
+ {error} +
+ )} - {/* Queue Jobs Table */}
- Outbound Jobs ({jobs.length}) + {t("admin.queueTitle")} ({jobs.length})
-
-
Alias addressDestino FinalDomainActions{t("ui.emailAddress")}{t("ui.destination")}{t("admin.domainsTitle")}{t("ui.actions")}
- - - - - - - - - - - - - - {jobs.map((j) => ( - - - - - - - - - + {jobs.length === 0 && !loading ? ( +
{t("admin.queueEmpty")}
+ ) : ( +
+
{t("ui.jobId")}{t("ui.sender")}{t("ui.recipient")}{t("ui.destination")}{t("ui.attempts")}{t("ui.lastError")}Status{t("ui.actions")}
{j.id}{j.sender}{j.recipient}{j.destinationDomain}{j.attempts}{j.lastError} - - {j.status} - - - - -
+ + + + + + + + + + - ))} - -
{t("ui.jobId")}{t("ui.sender")}{t("ui.recipient")}{t("ui.destination")}{t("ui.attempts")}{t("ui.lastError")}{t("common.status")}{t("ui.actions")}
-
+ + + {jobs.map((j) => ( + + {j.id.slice(0, 8)} + {j.envelope_from} + {j.envelope_to} + {j.destination_domain} + {j.attempt} + + {j.last_error + ? `${j.last_smtp_code ? `${j.last_smtp_code} ` : ""}${j.last_error}` + : t("common.none")} + + + + {j.status} + + + + + + + + ))} + + +
+ )}
); diff --git a/services/webmail/src/app/(admin)/admin/security/page.tsx b/services/webmail/src/app/(admin)/admin/security/page.tsx index 02ba906..3643b42 100644 --- a/services/webmail/src/app/(admin)/admin/security/page.tsx +++ b/services/webmail/src/app/(admin)/admin/security/page.tsx @@ -8,11 +8,7 @@ import { ListOrdered, CheckCircle2, Search, - FileText, - Key, Shield, - Activity, - AlertTriangle, } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card"; @@ -64,6 +60,15 @@ export default function AdminSecurityPage() { const [readiness, setReadiness] = useState(null); const [tracedSteps, setTracedSteps] = useState<{ step: number; title: string; detail: string; status: string }[] | null>(null); + /** + * Live certificate state from the protocols service, which owns the cert + * watcher. The panel this replaced was three static cards - "Let's Encrypt + * RSA 4096", "3 1 1 SHA-256 Verified", "mode=enforce" - plus a fixed list of + * cipher suites, none of it read from anywhere. It reported a healthy, + * DANE-verified certificate on a deployment serving a self-signed one. + */ + const [tlsStatus, setTlsStatus] = useState | null>(null); + React.useEffect(() => { fetch("/api/v1/admin/rspamd/thresholds") .then((r) => (r.ok ? r.json() : null)) @@ -93,18 +98,32 @@ export default function AdminSecurityPage() { ) .catch(() => undefined); + void fetch("/api/v1/admin/tls") + .then((r) => (r.ok ? r.json() : null)) + .then(setTlsStatus) + .catch(() => undefined); + void fetch("/api/v1/admin/preflight") .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data && Array.isArray(data.checks)) { setReadiness( - data.checks.map((c: { name: string; status: string; detail?: string }, index: number) => ({ - id: index + 1, - name: c.name, - category: "Security and DNS", - status: c.status === "PASS" ? "PASSED" : c.status === "FAIL" ? "FAILED" : "WARNING", - details: c.detail ?? "", - })) as PreflightCheck[] + // The service sends a key and its subject, so the label is built + // in the reader's language. It used to send a finished English + // sentence ("DNS records for example.com") that appeared verbatim + // in an otherwise translated console. + data.checks.map( + ( + c: { key?: string; target?: string; name: string; status: string; detail?: string }, + index: number, + ) => ({ + id: index + 1, + name: c.key && c.target ? t(`admin.${c.key}`, { domain: c.target }) : c.name, + category: t("ui.domainVerification"), + status: c.status === "PASS" ? "PASSED" : c.status === "FAIL" ? "FAILED" : "WARNING", + details: c.detail ?? "", + }), + ) as PreflightCheck[] ); } }) @@ -142,13 +161,11 @@ export default function AdminSecurityPage() { })) ); } else { - setTracedSteps([ - { step: 1, title: "Inbound SMTP TCP Handshake", detail: `Queue ID ${searchQueueId}: TLS 1.3 connection accepted`, status: "OK" }, - { step: 2, title: "Verification SPF / DKIM", detail: "SPF pass, DKIM signature on default._domainkey valid", status: "OK" }, - { step: 3, title: "Rspamd spam scan", detail: "Score 0.8 / 15.0 (symbols: BAYES_HAM, DKIM_TRACE)", status: "OK" }, - { step: 4, title: "Transactional outbox write", detail: "Written to mail_messages with SKIP LOCKED", status: "OK" }, - { step: 5, title: "Webmail WebSocket Push", detail: "Event published to the mailbox websocket hub de entrada", status: "ENTREGUE" }, - ]); + // An empty result means the queue id was not found. This used to + // invent a five-step delivery trace instead - TLS 1.3 accepted, SPF + // pass, Rspamd score 0.8 - for a message that does not exist, which is + // the worst possible answer to "what happened to this mail?". + setTracedSteps([]); } } catch { // fallback @@ -163,9 +180,7 @@ export default function AdminSecurityPage() {

{t("admin.securityTitle")}

-

- Readiness scorecard, TLS policy, Rspamd thresholds and the audit trail. -

+

{t("admin.securitySubtitle")}

{/* Tab Selector Controls */} @@ -179,7 +194,7 @@ export default function AdminSecurityPage() { }`} > - Placar 10/10 + {t("admin.tabReadiness")}
@@ -223,20 +238,42 @@ export default function AdminSecurityPage() { {/* TAB 1: 10/10 READINESS SCORECARD */} {activeTab === "readiness" && (
- + {/* The score is counted from the checks that came back. It used to + read a hardcoded "10 / 10" with an "all checks passed" badge + regardless of what the preflight endpoint reported - including + when it reported failures, or nothing at all. */} +

- Mail server readiness scorecard + {t("admin.preflightTitle")}

-

- Ten non-negotiable checks before this node carries production mail. -

-
-
- 10 / 10 - TODAS AS CHECAGENS APROVADAS +

{t("admin.readinessIntro")}

+ {readiness && readiness.length > 0 ? ( + (() => { + const passed = readiness.filter((c) => c.status === "PASSED").length; + const total = readiness.length; + return ( +
+ + {passed} / {total} + + + {passed === total + ? t("ui.allChecksPassed") + : t("admin.checksPassed", { passed, total })} + +
+ ); + })() + ) : ( + {t("admin.noChecks")} + )}
@@ -257,42 +294,63 @@ export default function AdminSecurityPage() {
)} - {/* TAB 2: TLS CERTIFICATES & POLICY PANEL */} + {/* TAB 2: TLS - read from the protocols service cert watcher */} {activeTab === "tls" && (

- TLS certificates and transport policy + {t("admin.tlsTitle")}

-
-
-
Certificado Traefik / ACME
-
Let's Encrypt RSA 4096
-
Renewal is automatic
-
-
-
Status Registros DANE TLSA
-
3 1 1 SHA-256 Verificado
-
Cadeia DNSSEC verificada na porta 25
-
+ {tlsStatus ? ( +
+
+
{t("admin.certificateState")}
+
+ {String(tlsStatus.state ?? "UNKNOWN")} +
+
+ {typeof tlsStatus.expires_in_days === "number" + ? t("admin.expiresInDays", { days: tlsStatus.expires_in_days }) + : t("admin.noCertificate")} +
+
-
-
MTA-STS policy mode
-
mode=enforce
-
max_age=604800 (7 dias)
-
-
- +
+
{t("admin.mailHost")}
+
+ {String(tlsStatus.mail_host ?? "-")} +
+
+ {t("admin.tlsMode")}: {String(tlsStatus.tls_mode ?? "-")} +
+
- -

Accepted TLS cipher suites (ports 25 / 587 / 993)

-
-
TLS_AES_256_GCM_SHA384 (TLS 1.3) [PREFERENCIAL]
-
TLS_CHACHA20_POLY1305_SHA256 (TLS 1.3)
-
ECDHE-ECDSA-AES256-GCM-SHA384 (TLS 1.2)
-
+
+
{t("admin.watcherHealthy")}
+
+ {tlsStatus.watcher_healthy ? t("ui.healthy") : t("common.unavailable")} +
+
+ {t("admin.lastReload")}:{" "} + {tlsStatus.last_reload + ? new Date(String(tlsStatus.last_reload)).toLocaleString() + : t("common.never")} +
+
+
+ ) : ( +
{t("errors.loadFailed")}
+ )}
)} @@ -303,21 +361,21 @@ export default function AdminSecurityPage() {

- Rspamd score thresholds + {t("admin.tabRspamd")}

-

Set the scores that trigger greylisting, header tagging and rejection.

+

{t("admin.rspamdIntro")}

{rspamdSaved && (
- Rspamd thresholds saved. + {t("admin.rspamdSaved")}
)}
- +
- +
- + @@ -364,9 +422,9 @@ export default function AdminSecurityPage() {

- Delivery trace by queue ID + {t("admin.traceTitle")}

-

Rastreie as etapas exatas de processamento de qualquer e-mail por Queue ID ou UUID.

+

{t("admin.tracePlaceholder")}

@@ -374,12 +432,12 @@ export default function AdminSecurityPage() { type="text" value={searchQueueId} onChange={(e) => setSearchQueueId(e.target.value)} - placeholder="Informe o Queue ID (ex: q-9812-ax)..." + placeholder={t("admin.tracePlaceholder")} required className="flex-1 px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono placeholder-slate-500 focus:outline-none focus:border-emerald-500" />
@@ -404,7 +462,7 @@ export default function AdminSecurityPage() { {/* Immutable System Audit Log Table */}
- Audit log + {t("admin.auditLog")}
diff --git a/services/webmail/src/app/(auth)/admin/login/page.tsx b/services/webmail/src/app/(auth)/admin/login/page.tsx index af8a2cd..0200cda 100644 --- a/services/webmail/src/app/(auth)/admin/login/page.tsx +++ b/services/webmail/src/app/(auth)/admin/login/page.tsx @@ -3,6 +3,7 @@ import React, { useState } from "react"; import Link from "next/link"; import { useTranslations } from "../../../../i18n/provider"; +import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment"; export default function AdminLoginPage() { const t = useTranslations(); @@ -33,7 +34,7 @@ export default function AdminLoginPage() { body: JSON.stringify({ code: mfaCode }), }); const data = await res.json(); - if (!res.ok) throw new Error(data.message || "Verification failed"); + if (!res.ok) throw new Error(data.message || t("auth.verificationFailed")); // Shown once. The operator signs in again with the factor they just // created, which is also the first proof that it works. setRecoveryCodes(data.recovery_codes ?? []); @@ -51,7 +52,7 @@ export default function AdminLoginPage() { body: JSON.stringify({ challenge_token: challengeToken, code: mfaCode }), }); const data = await res.json(); - if (!res.ok) throw new Error(data.message || "Admin verification failed"); + if (!res.ok) throw new Error(data.message || t("auth.verificationFailed")); window.location.href = "/admin/dashboard"; return; } @@ -72,14 +73,14 @@ export default function AdminLoginPage() { headers: { Authorization: `Bearer ${data.enrollment_token}` }, }); const enrolData = await enroll.json(); - if (!enroll.ok) throw new Error(enrolData.message || "Could not start enrolment"); + if (!enroll.ok) throw new Error(enrolData.message || t("auth.enrolmentFailed")); setEnrolmentToken(data.enrollment_token); setEnrolSecret(enrolData.secret); setEnrolUri(enrolData.uri); return; } - if (!res.ok) throw new Error(data.message || "Admin authentication failed"); + if (!res.ok) throw new Error(data.message || t("auth.authFailed")); if (data.mfa_required) { setChallengeToken(data.challenge_token); @@ -87,7 +88,7 @@ export default function AdminLoginPage() { window.location.href = "/admin/dashboard"; } } catch (err: unknown) { - setError(err instanceof Error ? err.message : "An error occurred"); + setError(err instanceof Error ? err.message : t("auth.genericError")); } finally { setLoading(false); } @@ -102,7 +103,7 @@ export default function AdminLoginPage() {

{t("auth.adminLoginTitle")}

-

Surface: /admin/* (Cookie: Path=/admin, MFA Mandatory)

+

{t("auth.adminStepUpNote")}

@@ -112,45 +113,24 @@ export default function AdminLoginPage() {
)} + {/* The recovery-code step is terminal: it owns its own buttons, so the + form's submit button is not rendered underneath it. That overlap is + what previously put two identical "sign in" buttons on the screen + and left no way to copy the codes it insisted you save. */} + {recoveryCodes ? ( + setRecoveryCodes(null)} + continueLabel={t("auth.signInButton")} + /> + ) : (
- {recoveryCodes ? ( -
-
-
{t("settings.recoveryCodesTitle")}
-

{t("settings.saveRecoveryCodes")}

-
- {recoveryCodes.map((code) => ( - {code} - ))} -
-
- -
- ) : enrolSecret ? ( -
+ {enrolSecret ? ( +
- {t("auth.mfaRequired")} -
-
- - - {enrolSecret} - - {enrolUri && ( - - {enrolUri} - - )} + {t("auth.enrolmentIntro")}
+
- {loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")} + {loading + ? t("common.loading") + : challengeToken || enrolSecret + ? t("auth.verifyCodeButton") + : t("auth.signInButton")} + )}
- - Back to Surface Selector + + {t("admin.backToWebmail")}
diff --git a/services/webmail/src/app/(auth)/user/login/page.tsx b/services/webmail/src/app/(auth)/user/login/page.tsx index 9211674..339efd1 100644 --- a/services/webmail/src/app/(auth)/user/login/page.tsx +++ b/services/webmail/src/app/(auth)/user/login/page.tsx @@ -2,7 +2,13 @@ import React, { useState } from "react"; import Link from "next/link"; -import { Mail, Lock, ShieldCheck, ArrowLeft, KeyRound, AlertCircle } from "lucide-react"; +import { + Mail, + Lock, + ShieldCheck, + KeyRound, + AlertCircle, +} from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; import { Card } from "../../../../components/ui/Card"; import { Button } from "../../../../components/ui/Button"; @@ -146,12 +152,6 @@ export default function UserLoginPage() { -
- - - Back to surface selection - -
); diff --git a/services/webmail/src/app/(user)/user/compose/page.tsx b/services/webmail/src/app/(user)/user/compose/page.tsx index 864542c..bc8f787 100644 --- a/services/webmail/src/app/(user)/user/compose/page.tsx +++ b/services/webmail/src/app/(user)/user/compose/page.tsx @@ -12,7 +12,6 @@ import { List, ListOrdered, Link as LinkIcon, - Trash2, Undo2, X, Sparkles, @@ -59,7 +58,7 @@ export default function ComposePage({ useEffect(() => { if (!to && !subject) return; const timer = setTimeout(() => { - setDraftStatus("Rascunho salvo automaticamente"); + setDraftStatus(t("mail.draftSaved")); }, 2500); return () => clearTimeout(timer); }, [to, subject]); @@ -148,13 +147,16 @@ export default function ComposePage({
- - {t("mail.undoSend")} ({undoSeconds}s restantes) - + {/* The countdown is a placeholder inside the message, not text + bolted on after it. It used to be both: the bundle's own + "{seconds}" was never substituted and a second, hardcoded + count was appended, so this read "Undo Send ({seconds}s + remaining) (9s remaining)". */} + {t("mail.undoSend", { seconds: undoSeconds })}
)} @@ -168,7 +170,7 @@ export default function ComposePage({ onClick={() => setShowCcBcc(!showCcBcc)} className="text-indigo-400 hover:text-indigo-300 font-medium hover:underline" > - {showCcBcc ? "- Ocultar Cc/Bcc" : "+ Adicionar Cc/Bcc"} + {showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
executeFormat("bold")} className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white" - title="Negrito" + title={t("mail.bold")} > @@ -239,7 +241,7 @@ export default function ComposePage({ type="button" onClick={() => executeFormat("italic")} className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white" - title="Italic" + title={t("mail.italic")} > @@ -247,7 +249,7 @@ export default function ComposePage({ type="button" onClick={() => executeFormat("underline")} className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white" - title="Sublinhado" + title={t("mail.underline")} > @@ -255,7 +257,7 @@ export default function ComposePage({ type="button" onClick={() => executeFormat("strikeThrough")} className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white" - title="Tachado" + title={t("mail.strikethrough")} > @@ -264,7 +266,7 @@ export default function ComposePage({ type="button" onClick={() => executeFormat("insertUnorderedList")} className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white" - title="Lista" + title={t("mail.bulletList")} > @@ -272,7 +274,7 @@ export default function ComposePage({ type="button" onClick={() => executeFormat("insertOrderedList")} className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white" - title="Lista Numerada" + title={t("mail.numberedList")} > @@ -280,11 +282,11 @@ export default function ComposePage({ @@ -302,10 +304,10 @@ export default function ComposePage({ {/* Attachment Selector & Display */}
- Anexos ({attachments.length}) + {t("mail.attachments", { count: attachments.length })}
+ {/* Says so plainly rather than letting someone attach a contract + and watch it silently not arrive: /mail/send is handed the file + names only, because there is no upload endpoint behind this + picker yet. */} + {attachments.length > 0 && ( +
+ {t("mail.attachmentsUnsupported")} +
+ )} + {attachments.length > 0 && (
{attachments.map((file) => ( @@ -343,13 +355,13 @@ export default function ComposePage({
diff --git a/services/webmail/src/app/(user)/user/layout.tsx b/services/webmail/src/app/(user)/user/layout.tsx index 8d80134..647ba1f 100644 --- a/services/webmail/src/app/(user)/user/layout.tsx +++ b/services/webmail/src/app/(user)/user/layout.tsx @@ -16,11 +16,14 @@ import { LogOut, Mail, ShieldCheck, + ShieldAlert, + Sliders, Menu, X, } from "lucide-react"; import { useTranslations } from "../../../i18n/provider"; import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher"; +import { useAccount, isAdminRole } from "../../../lib/useAccount"; interface FolderCount { special_use: string; @@ -33,6 +36,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN const pathname = usePathname(); const [folders, setFolders] = useState([]); const [mobileOpen, setMobileOpen] = useState(false); + const account = useAccount("user"); useEffect(() => { void fetch("/api/v1/mail/folders") @@ -48,9 +52,9 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN { href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" }, { href: "/user/mail/sent", label: t("mail.sent"), icon: Send, role: "sent" }, { href: "/user/mail/drafts", label: t("mail.drafts"), icon: FileText, role: "drafts" }, - { href: "/user/mail/archive", label: "Archive", icon: Archive, role: "archive" }, - { href: "/user/mail/junk", label: "Spam", icon: AlertTriangle, role: "junk" }, - { href: "/user/mail/trash", label: "Trash", icon: Trash2, role: "trash" }, + { href: "/user/mail/archive", label: t("mail.archive"), icon: Archive, role: "archive" }, + { href: "/user/mail/junk", label: t("mail.junk"), icon: AlertTriangle, role: "junk" }, + { href: "/user/mail/trash", label: t("mail.trash"), icon: Trash2, role: "trash" }, ]; const handleLogout = () => { @@ -134,6 +138,21 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
+ {/* The console is reachable from inside the app for the accounts that + may open it, which is why there is no longer a surface chooser in + front of the sign-in. It is a plain link to the admin sign-in, not a + direct jump: the admin audience is a separate token, so crossing + over means proving the second factor again. */} + {isAdminRole(account?.role) && ( + + + {t("admin.openAdmin")} + + )} +
-
- U +
+ {account?.email?.[0] ?? "?"}
-
user@lambdamail.local
+
+ {account?.email ?? t("common.loading")} +
+ {/* Reports what is actually configured. The old fixed "2FA + active" reassured accounts that had no second factor at all. */}
- 2FA Ativo + {account?.mfa_enrolled ? ( + <> + + {t("settings.mfaEnabled")} + + ) : ( + <> + + {t("settings.mfaDisabled")} + + )}
diff --git a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx index 0f1a795..b16cf56 100644 --- a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx +++ b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx @@ -8,10 +8,8 @@ import { Paperclip, Reply, Forward, - ShieldAlert, Inbox, Filter, - CheckCircle2, Image as ImageIcon, Mail, Clock, @@ -158,7 +156,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s : "bg-slate-800/60 text-slate-400 hover:text-slate-200" }`} > - Todos ({messages.length}) + {t("mail.allMessages")} ({messages.length})
@@ -366,7 +364,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s {bodyHtml ? (