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 ? (