diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 20c28f4..93ce43f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -49,6 +49,7 @@ jobs: - run: npm ci --ignore-scripts - run: npm run typecheck - run: npm run lint + - run: npm run i18n:check - run: npm test - run: npm run build diff --git a/package.json b/package.json index 14c243d..13c580f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "test": "vitest run", "test:watch": "vitest", "format": "prettier --write src/", - "format:check": "prettier --check src/" + "format:check": "prettier --check src/", + "i18n": "node tools/i18n-extract.mjs", + "i18n:check": "node tools/i18n-extract.mjs --check" }, "dependencies": { "@daypicker/react": "^10.0.1", diff --git a/src/features/audit/AuditLogPage.tsx b/src/features/audit/AuditLogPage.tsx index fecd07b..778a0a3 100644 --- a/src/features/audit/AuditLogPage.tsx +++ b/src/features/audit/AuditLogPage.tsx @@ -515,9 +515,9 @@ function ChangeTable({ changes }: { changes: AuditChange[] }) { - - - + + + @@ -526,7 +526,7 @@ function ChangeTable({ changes }: { changes: AuditChange[] }) { {change.redacted ? ( ) : ( <> diff --git a/src/i18n/en.json b/src/i18n/en.json index d9c3b81..23e863b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -152,7 +152,18 @@ "if": "IF", "result": "Result", "then": "THEN", - "value": "Value" + "value": "Value", + "editAsChoices": "Edit as choices", + "choose": "Choose…", + "customValue": "Custom expression…", + "chooseVariable": "Choose what to check…", + "conditionValue": "Value", + "when": "When", + "removeCondition": "Remove condition", + "use": "Use", + "otherwise": "Otherwise", + "addACondition": "Add a condition", + "editAsText": "Edit as text" }, "field": { "add": "Add", @@ -174,7 +185,11 @@ "time": "Time", "type": "Type", "typeAndPressEnter": "Type and press Enter...", - "unknownObjectType": "Unknown object type: {{name}}" + "unknownObjectType": "Unknown object type: {{name}}", + "on": "On", + "off": "Off", + "default": "Default: {{value}}", + "changed": "changed" }, "filters": { "after": "After", @@ -212,7 +227,13 @@ "stay": "Stay", "unsavedChanges": "You have unsaved changes. Are you sure you want to leave?", "unsavedChangesLabel": "Unsaved changes", - "unsavedChangesTitle": "Unsaved changes" + "unsavedChangesTitle": "Unsaved changes", + "resetToDefault": "Reset to default", + "resetTitle": "Put these back to their defaults?", + "resetBody": "The form fills in the defaults. Nothing changes on the server until you save, and Cancel leaves it as it was.", + "resetFromTo": "{{now}} → {{was}}", + "resetTo": "→ {{was}}", + "resetConfirm": "Fill in defaults" }, "globalSearch": { "create": "Create", @@ -415,5 +436,1242 @@ "theme": { "menu": "Theme", "classic": "Classic" + }, + "search": "Search", + "wizard": { + "chooseHow": "How would you like to do this?", + "guided": "Guide me", + "manual": "I'll do it myself", + "manualHint": "The full form, with every option at once.", + "close": "Close", + "progress": "Progress", + "back": "Back", + "next": "Next", + "finish": "Finish" + }, + "explain": { + "spamVerdictTitle": "Spam verdict: {{result}}", + "button": "Explain", + "buttonAbout": "Explain {{what}}", + "panelLabel": "Explanation", + "title": "Explanation", + "close": "Close", + "writing": "Still writing…", + "asking": "Asking the local model…", + "cancel": "Cancel", + "retry": "Try again", + "caution": "This is the local model's reading, and it can be wrong. Check it against the details shown.", + "copied": "Copied", + "copy": "Copy", + "bylinePrepared": "Prepared for release {{release}} with {{model}}", + "bylineRemembered": "Local AI · {{model}} · answered earlier on {{node}}", + "byline": "Local AI · {{model}} · {{node}}", + "busy": "The model is busy with incoming mail. Try again in a moment.", + "timeout": "The model didn't answer in time.", + "paused": "The model is paused after repeated failures. It will be tried again shortly.", + "rateLimit": "You've asked for as many explanations as this hour allows.", + "unavailable": "No local model is available to explain this right now.", + "settingsSaved": "Explanation settings saved.", + "settingsTitle": "Explanations", + "settingsLead": "An Explain button beside delivery failures, spam verdicts, log and trace events, and settings asks the model for a short explanation in plain words. It never takes a slot the spam filter needs, and message text is never sent.", + "enabled": "Offer explanations", + "model": "Model", + "modelDefault": "Same as the spam filter", + "perHour": "Per administrator, per hour", + "ceiling": "Longest wait", + "failedRecipients": "Failed deliveries", + "permanent": "Permanent failure", + "temporary": "Temporary failure" + }, + "localAi": { + "turnedOff": "Local AI spam filtering is off.", + "failed": "That didn’t work", + "title": "Local AI", + "subtitle": "Spam filtering with a language model you run on your own machines. Off until you turn it on.", + "howTitle": "Guided or manual?", + "guided": "Guided", + "manual": "Manual: the model and classifier forms", + "on": "On", + "asks": "Asks {{name}} ({{model}}) at {{url}}", + "off": "Off", + "offExplain": "The spam filter doesn’t use a language model. Nothing is sent anywhere until you set one up.", + "turnOff": "Turn off", + "editClassifier": "Edit the classifier", + "setUp": "Set up local AI spam filtering", + "turnedOn": "Local AI spam filtering is on.", + "whatLead": "The spam filter will ask a language model for its opinion of each incoming message.", + "whatSent": "Only the subject and text are sent: no addresses, headers or attachments.", + "whatBounded": "Its opinion is one signal among many, adding at most {{max}} points by default.", + "whatNeverHolds": "If the model is slow or down, mail is never held up.", + "whatModel": "Run the model yourself, on this machine or your own network: llama.cpp’s server or Ollama both work. We recommend {{label}} ({{license}}) with at least {{cpus}} CPU cores.", + "url": "Model address", + "urlHint": "llama.cpp: {{llama}} · Ollama: {{ollama}}", + "model": "Model name", + "name": "Name in inbuxa", + "prompt": "Instructions for the model", + "promptHint": "The default was measured against real mail. The server adds its own framing so the message is treated as data, not instructions.", + "stepOf": "Step {{n}} of {{total}}", + "turnOn": "Turn on", + "limitsUnavailable": "This server doesn’t offer AI limits.", + "limitsSaved": "Limits saved.", + "limitsTitle": "Limits", + "limitsLead": "These keep the model’s influence small and its load bounded. The defaults suit a small CPU-only server.", + "limitDefault": "Default: {{value}}", + "seconds": "s", + "resetDefaults": "Reset to defaults" + }, + "alertTemplates": { + "failed": "The alert could not be added.", + "none": "No alerts yet: nothing tells you when something goes wrong.", + "title": "In plain words", + "add": "Add one from a template", + "inUse": "In use", + "when": "When {{when}}.", + "howOften": "Alerts are checked every minute and email once when their condition turns true. The queue and memory alerts can fire again after things recover; the others fire the first time, and again only after a restart.", + "whenLong": "Email when {{when}}.", + "to": "Email", + "toHint": "One or more addresses, separated by commas. Preferably one not hosted on this server.", + "from": "From", + "event": "Also raise an event, for webhooks", + "create": "Add alert" + }, + "audit": { + "unavailable": "This server does not keep an audit log.", + "verifyFailed": "The check could not run", + "title": "Audit log", + "subtitle": "Every change administrators and the server made, administrator sign-ins, and access to other accounts. Records can’t be edited or deleted.", + "keptFor": "Kept for {{days}} days.", + "change": "Change", + "verify": "Check for tampering", + "export": "Export…", + "outcome": { + "success": "Done", + "refused": "Refused", + "pendingHint": "The server stopped before it finished.", + "pending": "Unfinished" + }, + "filter": { + "text": "Search", + "textHint": "Name, address, object, reason…", + "from": "From", + "to": "To", + "action": "What happened", + "any": "Anything", + "outcome": "Outcome", + "apply": "Show", + "clear": "Clear" + }, + "noneMatching": "No records match these filters.", + "none": "Nothing has been recorded yet.", + "col": { + "when": "When", + "who": "Who", + "what": "What", + "target": "To", + "outcome": "Outcome" + }, + "showing": "Showing {{from}}–{{to}} of {{total}}", + "previous": "Newer", + "next": "Older", + "detail": { + "who": "Who", + "from": "From", + "outcome": "Outcome", + "reason": "Reason given", + "details": "Details", + "record": "Record", + "node": "node {{node}}" + }, + "changes": { + "field": "Setting", + "before": "Before", + "after": "After", + "secret": "Changed. Secrets are never recorded." + }, + "exported": "Exported {{count}} records", + "exportedHash": "SHA-256 {{hash}}", + "exportFailed": "Nothing was exported", + "exportTitle": "Export the audit log", + "exportBody": "The records the filters above match, up to 100,000, newest first. Each line carries its chain hash, and the file ends with a manifest, so whoever receives it can check it. The export itself is recorded.", + "format": "Format", + "formatCsv": "CSV (spreadsheets)", + "formatJsonl": "JSON Lines (tools, SIEM)", + "reason": "Reason (optional)", + "reasonHint": "Ticket, request or case this is for", + "cancel": "Cancel", + "exportGo": "Export", + "verified": "No tampering found", + "broken": "The audit log was changed", + "verifiedBody": "Every record on every node follows from the one before it. This catches edits to the store; it can’t stop someone with root on the server rewriting everything, so forward records to a system your mail administrators don’t control as well.", + "brokenBody": "At least one record doesn’t follow from the one before it. Keep the server as it is and compare against an export or a forwarded copy.", + "chainNode": "Node {{node}}", + "chainBroken": "broken at record {{at}}", + "chainOk": "{{count}} entries verified", + "chainUnfinished": "{{count}} changes never recorded an outcome.", + "retentionFailed": "Not saved", + "retentionTitle": "How long records are kept", + "retentionBody": "Older records are removed once a day. At least 90 days; the default is two years. This change is recorded too.", + "retentionDays": "Days", + "save": "Save", + "action": { + "create": "Created", + "update": "Changed", + "destroy": "Deleted", + "signIn": "Signed in", + "signInFailed": "Sign-in failed", + "accountAccess": "Opened another account", + "blobAccess": "Read another account’s file", + "export": "Exported the audit log", + "verify": "Checked for tampering" + }, + "via": { + "password": "password", + "appPassword": "app password", + "apiKey": "API key", + "oauth": "app {{client}}", + "directory": "directory token", + "master": "as master user {{name}}", + "recovery": "recovery administrator" + } + }, + "certCard": { + "chooseTitle": "Certificates, automatically", + "guidedHint": "Pick domains, check every name first, and watch Let’s Encrypt certificates arrive. About two minutes.", + "manualHint": "Add an ACME provider, then set each domain’s Certificate management yourself.", + "title": "Free certificates that renew themselves", + "hint": "A guide checks your domains’ names and sets up Let’s Encrypt, so certificates are issued and renewed for you.", + "start": "Set it up" + }, + "certSetup": { + "stepDomains": "Domains", + "stepCheck": "Check names", + "stepAccount": "Account", + "stepReview": "Review", + "stepWatch": "Watch them arrive", + "partial": "These domains were not switched: {{names}}", + "title": "Certificates, automatically", + "subtitle": "Free certificates from Let’s Encrypt for your domains, renewed by the server before they expire.", + "howTitle": "How the server proves it’s yours", + "howDns": "A domain whose DNS is connected is checked through DNS: the certificate covers the domain and everything under it (*.domain), and nothing needs to reach this server.", + "howTls": "Otherwise Let’s Encrypt connects to this server on port 443 for each name. Those names must point straight at this server, not through a proxy.", + "noDomains": "There are no domains yet.", + "addDomain": "Add one first", + "alreadyAuto": "Already automatic.", + "manual": "Certificates managed by hand.", + "validUntil": "Current certificate valid until {{date}}.", + "noCert": "No certificate yet.", + "viaDns": "Checked through DNS.", + "viaTls": "Checked on port 443.", + "checkedWith": "How this is checked", + "checkedHow": "Each name is looked up in public DNS through {{resolver}}, and compared with where {{server}} points. Nothing is ordered yet.", + "thisServer": "this server", + "whyAll": "Why every name matters", + "whyAllText": "One certificate covers all of a domain’s names. If a single name fails, the whole order fails and keeps retrying.", + "dnsReady": "{{domain}}: checked through its connected DNS, covering {{names}}.", + "noAnchor": "This server’s own address couldn’t be looked up, so names are only checked for existing.", + "ok": "points here", + "missing": "doesn’t exist: add a CNAME to {{server}}", + "proxied": "goes through the Cloudflare proxy: set it to DNS only (grey cloud)", + "elsewhere": "points somewhere else ({{ips}})", + "lookupFailed": "couldn’t be looked up", + "fixOr": "Fix those names in DNS and check again, or", + "connectDns": "connect this domain’s DNS", + "connectDnsWhy": "so it’s checked through DNS instead.", + "anyway": "Order anyway (it will keep retrying until the names are fixed)", + "checkAgain": "Check again", + "stagingTitle": "A practice run", + "stagingText": "Let’s Encrypt’s staging service issues certificates that browsers don’t trust, with far looser limits. Use it to see everything work, then run this guide again without it.", + "contact": "Your email address", + "contactHint": "Let’s Encrypt writes here only about problems with your account or certificates. Creating the account accepts their Subscriber Agreement.", + "agreement": "Read it", + "reuse": "The Let’s Encrypt account already set up on this server will be used.", + "staging": "Practice run (staging)", + "stagingHint": "Certificates browsers won’t trust. For trying this out.", + "apply": "Switch them to automatic", + "undoTitle": "Changing your mind", + "undo": "Set a domain’s Certificate management back to Manual. Certificates already issued stay until they expire.", + "willChange": "What will change", + "newAccount": "A Let’s Encrypt {{kind}}account is added for {{email}}.", + "stagingWord": "staging ", + "domainAuto": "{{domain}} gets its certificate automatically, checked {{how}}: {{names}}.", + "howDnsWord": "through DNS", + "howTlsWord": "on port 443", + "skipped": "Left as they are until their names are fixed: {{names}}.", + "issued": "Issued. Valid until {{date}}.", + "failed": "Failed.", + "retrying": "Retrying: {{reason}}", + "coveredAlready": "Already covered by a certificate valid until {{date}}; it renews on {{due}}.", + "waiting": "Ordering…", + "afterTitle": "From now on", + "after": "The server renews each certificate well before it expires. Failed renewals show under Management › Tasks › Failed.", + "keepWatching": "Keep watching", + "watchingHint": "Checking every few seconds. Most certificates arrive within a minute.", + "tabTitle": "Certificates" + }, + "overview": { + "first": "First record", + "dCollected": "collected", + "dUnbounded": "no limit", + "dLeaving": "leaving", + "dHosts": "hosts", + "noCount": "Same counts", + "title": "Compliance overview", + "subtitle": "What this server holds about people, what has no time limit, who changed what affects review, and when the picture last changed.", + "collected": "Kinds of personal data this server can hold", + "unbounded": "Kept with no time limit", + "leaving": "Sent off this server", + "hosts": "Hosts receiving personal data", + "toInventory": "See the data inventory", + "noLimitTitle": "Kept with no time limit", + "noLimitNone": "Everything this server holds has a limit or goes with what it belongs to.", + "limitWith": "A limit can be set: {{setting}}", + "noSetting": "No setting limits it", + "reviewTitle": "Changes that affect review, last {{days}} days", + "reviewBody": "Audit and log retention, tracers, webhooks, roles and legal holds, with who changed them. Every one is in the audit log.", + "reviewNone": "None in this period.", + "historyTitle": "When the picture changed", + "historyNone": "Nothing recorded yet. A record is made when a setting the inventory reads changes, and checked daily.", + "byChange": "after {{setting}} changed", + "byDaily": "daily check" + }, + "inventory": { + "unavailable": "This server doesn’t report a data inventory.", + "title": "Data inventory", + "subtitle": "What this server holds about people, worked out from its settings as they are now: where each kind of data lives, how long it’s kept, and where it goes.", + "anyKind": "Every source", + "objects": "Stored records", + "sources": "Files, exports and lookups", + "anyCategory": "Any kind of data", + "anyPlace": "Anywhere", + "showOff": "Also show what this server doesn’t collect", + "colWhat": "What", + "colHolds": "Holds", + "colWhose": "Whose", + "colWhere": "Where", + "colKept": "Kept", + "notCollected": "Not collected here", + "leaves": "Leaves this server", + "none": "Nothing matches.", + "hostsTitle": "Hosts that receive personal data", + "hostsBody": "Each is a candidate processor. Whether it is one, and what agreement it needs, is for you to determine.", + "noHosts": "Nothing this server collects is sent to another host.", + "evaluated": "Worked out {{when}}, from the catalog in {{version}}." + }, + "cluster": { + "quietUnknown": "not heard from", + "quietMinutes_one": "quiet for {{count}} minute", + "quietMinutes_other": "quiet for {{count}} minutes", + "quietHours_one": "quiet for {{count}} hour", + "quietHours_other": "quiet for {{count}} hours", + "health": "Cluster Health", + "healthy_one": "{{count}} healthy node", + "healthy_other": "{{count}} healthy nodes", + "unhealthy_one": "{{count}} unhealthy", + "unhealthy_other": "{{count}} unhealthy", + "healthLink": "Cluster Health: see the nodes" + }, + "queue": { + "scheduled": "Waiting its turn", + "retrying": "Retrying", + "failed": "Gave up", + "title": "Where mail is waiting", + "subtitle": "Outgoing recipients still in the queue, by destination", + "open": "Open the queue", + "empty": "Nothing waiting. Everything has gone out.", + "rowTitle": "{{domain}}: {{s}} waiting, {{r}} retrying, {{f}} gave up", + "more": "+{{count}} more destinations" + }, + "status": { + "and": " and " + }, + "storage": { + "others": "{{count}} others", + "title": "Who uses the space", + "subtitle": "{{total}} across {{count}} people", + "empty": "No one has stored anything yet.", + "nearFull_one": "{{count}} nearly full", + "nearFull_other": "{{count}} nearly full", + "seePeople": "See people", + "emptyHint": "Tiles appear here as people store mail and files.", + "topOnly": "The {{count}} biggest are shown on their own." + }, + "rhythm": { + "title": "Your mail’s weekly rhythm", + "subtitle": "Messages handled by hour and day, over the period above", + "cell": "{{day}} {{hour}}:00, {{count}} messages" + }, + "dirCard": { + "chooseTitle": "Connect a sign-in directory", + "guidedHint": "Pick your directory, fill in the connection, test a person, then choose domains. Nothing moves until the test passes.", + "manualHint": "Create the directory and set each domain’s directory yourself.", + "title": "Sign in with accounts you already have", + "hint": "A guide connects Active Directory, LDAP or an OpenID Connect provider, tests a real person, then moves the domains you pick.", + "start": "Set it up" + }, + "dirSetup": { + "stepKind": "Directory", + "stepConnect": "Connection", + "stepTest": "Test a person", + "stepDomains": "Domains", + "stepReview": "Review", + "stepDone": "Done", + "description": "{{name}} (set up with the guide)", + "notLoaded": "The server hasn’t loaded the directory yet.", + "title": "Connect a sign-in directory", + "subtitle": "Let people sign in with the accounts they already have in Active Directory, LDAP or an OpenID Connect provider.", + "safeTitle": "Nothing changes yet", + "safe": "The directory is saved and tested on its own first. Sign-in only moves to it for the domains you pick at the end.", + "sql": "An SQL database as a directory is set up by hand, under Directories.", + "saveNext": "Save and continue", + "bindTitle": "The lookup account", + "bind": "A read-only account the server uses to find people. Each person’s own password is then checked by signing in as them.", + "oidcTitle": "At the provider", + "oidc": "Register inbuxa as a client there. Mail apps can’t do browser sign-in, so people use app passwords for IMAP and SMTP.", + "url": "Server", + "urlHint": "ldaps:// on 636 is encrypted from the start. ldap:// uses STARTTLS unless it’s this machine.", + "baseDn": "Search base", + "bindDn": "Lookup account", + "bindPassword": "Lookup account password", + "keep": "Leave empty to keep the saved one", + "invalidCerts": "Accept an untrusted certificate", + "invalidCertsHint": "Only for a self-signed test server. Anyone in between could read passwords.", + "issuer": "Issuer URL", + "usernameDomain": "Domain for plain user names (optional)", + "usernameDomainHint": "Added to a user name without @, to make the address.", + "testAddress": "A person to test with", + "testTitle": "What the test does", + "testHow": "The server looks the address up in the directory and, if you give a password, signs in as that person. Nothing is created here, and a wrong password doesn’t count against anyone.", + "testPassword": "Their password (optional)", + "test": "Test", + "testOidc": "Check the provider", + "oidcOk": "The provider answered, as {{issuer}}.", + "opened": "The server can use this directory.", + "notOpened": "The directory didn’t open: {{error}}", + "lookupError": "The lookup failed: {{error}}", + "found": "Found {{name}} <{{email}}>.", + "foundGroup": "That address is a group, not a person. Try a person’s address.", + "notFound": "Nobody with that address. Check the search base and that the address is in the mail attribute.", + "aliases": "Also receives: {{list}}. ", + "groups": "Groups: {{list}}.", + "signInOk": "The password signs in.", + "wrongPassword": "The directory says that password is wrong.", + "signInError": "Signing in failed: {{error}}", + "fixHint": "Go back to change the connection. Saving again updates the same directory.", + "whoTitle": "Who this affects", + "who": "On a domain that moves to this directory, only people the directory knows can sign in with a password. Existing accounts it doesn’t know stop signing in and, for LDAP, stop receiving mail. App passwords keep working.", + "alreadyThis": "Already uses this directory.", + "otherDir": "Uses another directory now.", + "defaultDir": "Uses the server default now.", + "accounts": "{{count}} accounts.", + "yours": "You sign in on this domain.", + "asDefault": "Make it the server default", + "asDefaultHint": "For every domain without a directory of its own. Rarely what you want: prefer choosing domains.", + "lockout": "This includes the account you’re signed in with ({{me}}).", + "lockoutHint": "If the directory doesn’t know you, you can’t sign in again with your password. Test your own address first, keep an app password, or leave that domain out.", + "understood": "I’ve checked I can still sign in", + "apply": "Switch sign-in over", + "undoTitle": "Changing your mind", + "undo": "Set a domain’s directory back to the server default. Accounts made from the directory stay, with the last password it supplied.", + "willChange": "What will change", + "domainMoves": "{{domain}} signs in through {{dir}}{{note}}.", + "countNote": "; its {{count}} existing accounts must be in the directory", + "defaultMoves": "Every other domain without its own directory signs in through {{dir}}.", + "done": "Sign-in now goes through {{dir}}.", + "doneHint": "Have someone sign in to the webmail with their directory password. Their account is created on first sign-in, with the name, aliases and groups the directory gives.", + "tabTitle": "Sign-in directory" + }, + "held": { + "noSubject": "(no subject)", + "readRecorded": "From {{sender}}. Reading it is recorded in the audit log.", + "released": "Released: it’s on its way", + "rejected": "Rejected: the sender was told", + "decideFailed": "Nothing was changed", + "releaseTitle": "Release “{{subject}}”", + "rejectTitle": "Reject “{{subject}}”", + "releaseBody": "It will be delivered to {{to}} as it was sent.", + "rejectBody": "It’s taken out of the queue, and {{sender}} is told it wasn’t sent.", + "note": "A note for the sender (optional)", + "reason": "Reason, for the audit log", + "cancel": "Cancel", + "releaseGo": "Release", + "rejectGo": "Reject", + "daysFailed": "Not saved", + "daysLabel": "Held mail waits", + "daysUnit": "days (1 to 90) for newly held mail", + "save": "Save", + "days_one": "If nobody decides within 1 day, it goes back to the sender.", + "days_other": "If nobody decides within {{count}} days, it goes back to the sender.", + "changeDays": "Change…", + "unavailable": "This server doesn’t hold mail for review.", + "title": "Held mail", + "subtitle": "Outgoing mail a DLP rule held for review. Release it and it’s delivered; reject it and the sender is told.", + "none": "Nothing is waiting for review.", + "fromTo": "{{sender}} to {{to}}", + "when": "Held {{held}}; goes back to the sender {{expires}} if nobody decides", + "read": "Read…", + "releaseAction": "Release…", + "rejectAction": "Reject…", + "words": "Words", + "pattern": "Pattern" + }, + "rules": { + "addTemplate": "Add a template…", + "needsWord": "(with a word nearby)", + "atLeast": "at least", + "times": "times", + "wordsHint": "One word or phrase per line", + "headerContains": "contains… (empty: the header exists)", + "pickGroup": "Add a group…", + "pickTenant": "Add a tenant…", + "disclaimerText": "The disclaimer", + "disclaimerHtml": "HTML version (optional; the text is used otherwise)", + "bottom": "At the bottom", + "top": "At the top", + "queueName": "Queue name", + "refuseText": "What the sender is told", + "warn": "Warn", + "warnBody": "The sender sees the notice and may send anyway, giving a reason that’s recorded.", + "hold": "Hold for review", + "holdBody": "It waits under Held mail until a reviewer releases or rejects it.", + "block": "Block", + "blockBody": "It isn’t sent, and the sender sees the notice.", + "notice": "Notice the sender sees", + "notifySender": "Tell the sender it’s held", + "saved": "Rule saved", + "notSaved": "Not saved", + "editTitle": "Change “{{name}}”", + "newDlp": "New DLP rule", + "newFlow": "New mail flow rule", + "name": "Name", + "priority": "Order", + "enabled": "On", + "description": "Description (optional)", + "direction": "Which mail", + "outgoing": "Mail sent from here", + "incoming": "Mail arriving here", + "any": "All mail", + "conditions": "When all of these are true", + "addCondition": "Add a condition…", + "exceptions": "Except when any of these is true", + "addException": "Add an exception…", + "then": "Then", + "addAction": "Add an action…", + "stop": "Stop here: later rules don’t run for this message", + "reason": "Reason, for the audit log (optional)", + "cancel": "Cancel", + "save": "Save", + "unavailable": "This server doesn’t have mail rules.", + "dlpTitle": "Data loss prevention", + "flowTitle": "Mail flow rules", + "dlpSubtitle": "Rules that check outgoing mail for what shouldn’t leave (card numbers, national ID numbers, keys, your own words) and warn, hold it for review, or block it. Every match is in the audit log, never what was found.", + "flowSubtitle": "Rules that change mail as it passes: disclaimers, banners, headers, copies, redirects, refusals. They run after the server’s own Sieve script, in the order below.", + "new": "New rule…", + "noneDlp": "No DLP rules yet: outgoing mail isn’t checked.", + "noneFlow": "No mail flow rules yet.", + "edit": "Change…", + "deleteConfirm": "Delete it", + "delete": "Delete" + }, + "dnsWizard": { + "stepProvider": "Your DNS host", + "stepConnect": "Connect", + "stepRecords": "Records", + "stepReview": "Review", + "stepLive": "Go live", + "stepCopy": "Add the records", + "noDomain": "That domain no longer exists.", + "providerDescription": "{{provider}} for {{domain}}", + "createFailed": "The provider could not be saved.", + "updateFailed": "The domain could not be switched to automatic DNS.", + "title": "Publish DNS for {{domain}}", + "subtitle": "Connect your DNS host once, and the server keeps these records right for you.", + "whatTitle": "What this does", + "whatBody": "Your mail server writes its own DNS records through your DNS host’s API: mail routing, the records that prove your mail is yours, and the ones mail apps use to set themselves up.", + "whatKeep": "When keys rotate or settings change, it updates them again. Nothing is changed until the last step.", + "detecting": "Looking up where {{domain}}’s DNS is hosted…", + "foundAt": "{{domain}}’s DNS is at {{provider}}", + "foundZone": "In the {{zone}} zone, served by {{ns}}.", + "foundNs": "Served by {{ns}}.", + "useExisting": "Use “{{name}}”", + "connectAnother": "Connect with a new key", + "connectDetected": "Connect {{provider}}", + "byHand": "Add the records by hand instead", + "unsupportedHost": "{{domain}}’s DNS is at {{host}}", + "notFound": "{{domain}} isn’t in public DNS yet", + "unsupportedHint": "The server can’t update that host for you, but adding the records by hand takes a few minutes, and this page checks each one as it goes live.", + "notFoundHint": "Once it’s registered and pointed at a DNS host, you can add the records by hand or connect the host here.", + "showRecords2": "Show me the records", + "notRight": "Not right? Choose your DNS host", + "chooseHost": "Using a host the server can update? Choose it", + "yourHost": "Your DNS host", + "searchHosts": "Search {{count}} DNS hosts…", + "keepNarrow": "Keep the key narrow", + "keepNarrowBody": "Give the server a credential that can edit DNS for this domain and nothing else. If it ever leaked, that’s all it could touch.", + "storedTitle": "Where it’s kept", + "storedBody": "It’s stored in the server’s settings and never shown again. You can replace or remove it on the DNS provider’s page.", + "openProvider": "Open {{provider}}", + "unsupported": "This provider needs settings the guided setup can’t take. Add it on the DNS providers page, then come back and choose it here.", + "recordsNote": "Your other records are safe", + "recordsNoteBody": "Only the names listed here are written. Records the server doesn’t own, like your website or other TXT entries, are left as they are.", + "recordNone": "none yet", + "recordCount_one": "{{count}} record", + "recordCount_other": "{{count}} records", + "origin": "DNS zone (optional)", + "originHint": "Only if {{domain}} lives inside a bigger zone at your host, for example mail.example.com kept in example.com.", + "publish": "Connect and publish", + "happensTitle": "What happens next", + "happensNew": "The {{provider}} connection is saved,", + "happensExisting": "Your existing connection is used,", + "happensRest": "{{domain}} switches to automatic DNS, and the server starts writing records straight away. Most hosts show them within a minute or two.", + "undoTitle": "Changing your mind", + "undoBody": "If anything fails here, nothing is kept. Later, switching the domain back to manual stops all updates, and the records already written stay where they are.", + "reviewDomain": "Domain", + "reviewHost": "DNS host", + "reviewRecords": "Records", + "reviewGroups": "across {{groups}}", + "reviewZone": "Zone", + "alreadyAuto": "This domain already publishes automatically; this updates its settings.", + "hideRecords": "Hide the records", + "showRecords": "Show the exact records", + "optional": "(optional)", + "done": "Done", + "howChecked": "How this is checked", + "howCheckedBody": "Every few seconds this page asks {{resolver}} for each record, so a green tick means the whole internet can see it.", + "undoLive": "Switch the domain’s DNS management back to manual to stop updates. Records already written stay.", + "allLive": "All set. {{domain}} is live.", + "progress": "{{live}} of {{total}} records are live", + "taskFailedShort": "Your DNS host turned the update down.", + "taskPending": "The server is writing the records to your DNS host…", + "keepsUpdating": "From now on the server keeps them up to date by itself.", + "waiting": "Written. Waiting for them to show up in public DNS, which can take a few minutes.", + "checkNow": "Check now", + "lastChecked": "Last checked {{time}}", + "failedRecords_one": "{{count}} record wasn’t written. Your DNS host said:", + "failedRecords_other": "{{count}} records weren’t written. Your DNS host said:", + "failedSaid": "Your DNS host said:", + "failedHelp": "Check that the credential can edit this zone, then fix it on the DNS provider’s page. The server retries on its own.", + "differentHelp": "An amber mark means that name has a different value right now, often an old record that hasn’t expired from caches yet.", + "tabTitle": "Publish DNS" + }, + "dnsCopy": { + "copied": "Copied", + "subtitle": "Add these at your DNS host. Each one ticks green as the internet sees it.", + "later": "I’ll finish later", + "howTitle": "How to add them", + "how1": "In your DNS host’s panel, add a record for each row: pick the type, paste the name and the value.", + "how2": "The name is shown the way most panels want it: “@” means {{zone}} itself. If yours asks for full names, add .{{zone}} to the end.", + "how3": "Set the TTL to Auto or one hour. If a record with the same name and type exists, replace it.", + "zoneHint": "For hosts that can import a zone file", + "copyZone": "Copy all as a zone file", + "priority": "priority {{p}}", + "copyName": "Copy name", + "copyValue": "Copy value" + }, + "dnsCard": { + "autoTitle": "The server keeps this domain’s DNS up to date", + "title": "Let the server publish your DNS records", + "autoHint": "See which records are live, or change what it publishes.", + "hint": "Connect your DNS host, such as Cloudflare, and skip copying records by hand. Optional.", + "review": "Check records", + "start": "Set it up", + "chooseTitle": "Automatic DNS", + "guidedHint": "Pick your DNS host, paste a key, choose records, and watch them go live. About two minutes.", + "manualHint": "Use the DNS Management setting below, with every option at once." + }, + "eventPicker": { + "common": "Common picks", + "search": "Search events, e.g. bounce, ban, certificate", + "chosen": "{{count}} chosen", + "none": "No events match." + }, + "webhookTest": { + "title": "Send a test event", + "body": "One sample event of type webhook.test, sent with the saved settings, even while the webhook is off. Save changes first to test them.", + "send": "Send test" + }, + "webhooks": { + "title": "What a webhook receives", + "body": "A POST with a JSON body holding one or more of the events you chose, batched at most once per throttle interval:", + "signature": "With a signature key set, each request carries X-Signature: the HMAC-SHA256 of the raw body under that key, in base64. Compute it on your side and compare before trusting the request.", + "try": "To see one arrive, choose “Failed sign-ins and bans” and sign in once with a wrong password. Failed deliveries are retried until “Discard after”." + }, + "legacyProtocols": { + "bannerLead": "Legacy mail protocols are", + "bannerOff": "off", + "bannerTail": "on this server. Only inbuxa webmail and JMAP apps can sign in.", + "bannerTailTenant": "for your organization. Only inbuxa webmail and JMAP apps can sign in.", + "review": "Review", + "unavailable": "This server does not offer the legacy protocols switch.", + "failed": "The switch did not change", + "title": "Legacy mail protocols", + "subtitle": "Turn off IMAP, POP3 or ManageSieve one at a time, or all of them with sending from mail apps, so that only inbuxa webmail and JMAP apps can reach this server.", + "strandedTitle": "Some listeners could not be reopened", + "strandedBody": "Their ports may be taken by something else, or need a restart to bind. They are kept, and can be tried again:", + "tryAgain": "Try again", + "turnOffAll": "Turn off all legacy protocols…", + "and": "and", + "statusOff": "Legacy mail protocols are off on this server.", + "statusSome_one": "{{list}} is off on this server.", + "statusSome_other": "{{list}} are off on this server.", + "statusOn": "Legacy mail protocols are on.", + "statusOffBody": "Only inbuxa webmail and JMAP apps can sign in.", + "statusSomeBody": "Mail apps can still use {{list}}, and send.", + "statusOnBody": "Mail apps can use IMAP, POP3 and ManageSieve.", + "changedAt": "Last changed {{when}}.", + "turnAllOn": "Turn them all back on", + "turnOn": "Turn legacy protocols back on", + "rowRefused": "Port open, sign-in refused", + "rowSubmission": "On; sign-in refused only with all three off", + "rowLocked": "Locked open", + "rowClosed": "Off, ports closed", + "rowOff": "Off", + "rowNoListener": "On, no listener", + "rowOn": "On", + "colProtocol": "Protocol", + "colPorts": "Ports", + "colUsed": "Used lately", + "colNow": "Now", + "colSwitch": "Switch", + "usedNobody": "Nobody", + "usedCount_one": "1 account", + "usedCount_other": "{{count}} accounts", + "scopeServer": "everyone on this server", + "lockNote": "Incoming mail (SMTP) and inbuxa webmail (JMAP) are locked open: closing them would stop mail arriving and lock everyone out, including you.", + "submissionNote": "Sending from mail apps goes on while any of IMAP, POP3 or ManageSieve is on.", + "thisOrganization": "this organization", + "scopeTenant": "everyone in {{organization}}", + "tenantOff": "Off for {{organization}}. Only inbuxa webmail and JMAP apps can sign in to its domains.", + "tenantSome": "Some are off for {{organization}}. Mail apps can still send, and use what is on.", + "tenantOn": "On for {{organization}}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.", + "offServerWide": "Off for the whole server", + "switchLabel": "{{protocol}} on", + "oneTitle": "Turn off {{protocol}}?", + "oneLead": "Mail apps using {{protocol}} will stop working for {{scope}}. The other protocols, sending from mail apps, and inbuxa webmail are not affected.", + "oneNobody": "No account used {{protocol}} in the last 30 days.", + "oneUsers_one": "1 account used {{protocol}} in the last 30 days:", + "oneUsers_other": "{{count}} accounts used {{protocol}} in the last 30 days:", + "oneMore": "and {{count}} more", + "onePorts": "Its ports close: {{ports}}. Your firewall and port forwarding are not changed.", + "oneNoPorts": "No {{protocol}} listener is configured, so no port closes.", + "oneUndo": "You can turn it back on at any time.", + "oneGo": "Turn off {{protocol}}", + "typeToConfirm": "To confirm, type", + "confirmAll": "Turn off all legacy protocols", + "impactNone": "No account used a legacy mail app in the last 30 days.", + "impactCount_one": "1 account used a legacy mail app in the last 30 days.", + "impactCount_other": "{{count}} accounts used a legacy mail app in the last 30 days.", + "impactLeadAll": "Their mail apps stop working when what they use is turned off:", + "statementTitle": "Only inbuxa webmail and JMAP apps will work.", + "statementLead": "Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone on this server.", + "statementLeadTenant": "Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {{organization}}.", + "statementApps": "Phone and desktop mail apps will stop receiving and sending mail. That’s iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.", + "statementFilters": "Filters managed from a mail app (ManageSieve) will stop working. Filters set in inbuxa webmail keep working.", + "statementUnaffected": "Incoming mail is not affected. Calendars and contacts are not affected.", + "statementWebmail": "People keep full access through inbuxa webmail, which can be installed as an app on phones and computers.", + "statementPorts": "The IMAP, POP3 and ManageSieve ports will close: {{list}}.", + "statementNoPorts": "No IMAP, POP3 or ManageSieve listeners are configured, so no ports will close.", + "statementSubmission": "Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and inbuxa webmail (JMAP) are not affected and cannot be turned off here.", + "firewallLead": "This does not change your firewall or port forwarding.", + "firewallBody": "inbuxa stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.", + "statementUndo": "You can turn legacy protocols back on at any time." + }, + "logRetention": { + "failed": "Not saved", + "title": "Log files", + "forever": "Rotated log files are kept with no limit. They record client IP addresses and email addresses.", + "days_one": "Rotated log files are deleted after 1 day, on every server.", + "days_other": "Rotated log files are deleted after {{count}} days, on every server.", + "change": "Change…", + "keepAll": "Keep every file", + "daysLabel": "Delete after (days)", + "save": "Save" + }, + "hold": { + "heldTitle": "Held: nothing in this account can be destroyed, and deleting it keeps its data.", + "wholeServer": "Every account on the server", + "scope": { + "accounts": "People", + "groups": "Groups", + "domains": "Domains", + "tenants": "Tenants" + }, + "addAccount": "Add a person", + "addGroup": "Add a group", + "addDomain": "Add a domain", + "addTenant": "Add a tenant", + "scopeHint": "Domains, groups and tenants count as they are now: an account added later is held, and one that leaves stays held.", + "reason": "Reason", + "reasonHint": "Why, for the audit log: counsel’s letter, a matter number…", + "updated": "Hold changed", + "placed": "Hold placed. What it covers can no longer be destroyed.", + "updateFailed": "Nothing changed", + "placeFailed": "The hold wasn’t placed", + "editTitle": "Change {{name}}", + "placeTitle": "Place a legal hold", + "editBody": "A hold can grow, never shrink: more accounts, or a wider date range. To hold less, release it and place a new one.", + "placeBody": "Nothing it covers can be destroyed: deletions are kept in the archive with no expiry, by any user, delegate or administrator, and deleted accounts keep their data. People aren’t told.", + "name": "Case name", + "reference": "Reference (optional)", + "referenceHint": "Matter or ticket number", + "description": "Description (optional)", + "covers": "What it covers", + "range": "Dates (optional, UTC)", + "from": "From", + "to": "To", + "rangeHint": "Leave both empty to hold everything. With dates, mail counts by when it arrived and events by when they start; contacts, files and filter scripts are held whole. Without an end, mail still to come is held too.", + "cancel": "Cancel", + "save": "Save", + "placeGo": "Place hold", + "released": "Hold released", + "releaseFailed": "The hold is still in place", + "releaseTitle": "Release {{name}}", + "releaseBody": "What only this hold kept gets its normal deadline back, and never less than 30 days from now, so a mistaken release can be undone by placing a new hold. Anything another hold covers stays held. A released hold stays listed, and can’t be put back.", + "typeName": "Type the case name to confirm: {{name}}", + "releaseGo": "Release", + "export": { + "started": "Export started", + "notStarted": "The export didn’t start", + "title": "Export {{name}}", + "body": "Collects everything this hold covers into one ZIP: mail as .eml, calendars as .ics, contacts as .vcf, files as they are, and the deleted items it keeps, with a manifest of SHA-256 checksums. It runs on the server; you can leave this page.", + "accounts": "People (leave empty for everyone the hold covers)", + "accountsHint": "Someone the hold doesn’t cover is left out of the file.", + "reasonHint": "Why, for the audit log: a production request, a matter number…", + "go": "Start the export", + "downloadFailed": "Couldn’t download the export", + "running": "Collecting", + "ready": "Ready", + "failed": "Failed", + "startedBy": "{{when}} by {{who}}", + "oneItem": "1 item", + "items": "{{n}} items", + "download": "Download", + "heading": "Exports", + "expiry": "Only the person who started an export can download it. The file is kept as a temporary upload (an hour, unless the upload time-to-live says otherwise); the record, with its checksum, stays.", + "action": "Export…" + }, + "allDates": "Everything, whatever its date", + "between": "{{from}} to {{to}}", + "since": "From {{from}}, and mail still to come", + "until": "Up to {{to}}", + "unavailable": "This server can’t place legal holds.", + "title": "Legal holds", + "subtitle": "Nothing a hold covers can be destroyed, by anyone, until it is released: deletions are kept with no expiry and deleted accounts keep their data. People aren’t told.", + "placeAction": "Place a hold…", + "none": "No hold is in place.", + "placedBy": "Placed {{when}} by {{who}}", + "widenAction": "Widen…", + "releaseAction": "Release…", + "keeps": "What it keeps now", + "oneAccount": "1 account", + "accounts": "{{n}} accounts", + "oneItem": "1 deleted item kept", + "items": "{{n}} deleted items kept", + "releasedHeading": "Released", + "releasedBy": "Placed {{placed}} by {{placedBy}}; released {{when}} by {{who}}: {{why}}" + }, + "hover": { + "automatic": "Automatic", + "manual": "Manual", + "enabled": "Receiving mail", + "disabled": "Turned off", + "people_one": "{{count}} person", + "people_other": "{{count}} people", + "dnsAllLive": "DNS is in place", + "dnsSome": "{{live}} of {{total}} key records live", + "dns": "DNS records", + "dkim": "Signing keys", + "certs": "Certificates", + "roleAdmin": "Administrator", + "roleCustom": "Custom role", + "roleUser": "User", + "storage": "Storage", + "role": "Role", + "groups": "Groups", + "since": "Here since", + "loading": "Looking…", + "unavailable": "No details available." + }, + "limitsSetup": { + "stepWho": "Who uses it", + "stepAdjust": "Limits", + "stepReview": "Review", + "stepDone": "Done", + "title": "Sending and receiving limits", + "subtitle": "Sensible rate limits for the people who use this server. Without any, one stolen password can send as much spam as it likes.", + "personal": "Me and my family", + "personalHint": "A handful of people you know. Low limits: nobody here sends in bulk.", + "organization": "A company or school", + "organizationHint": "Tens to hundreds of known people, some of whom send a lot in a day.", + "hosting": "Open to the public", + "hostingHint": "People you don’t know sign up. Tight sending limits, because compromised accounts are the risk.", + "handTitle": "Your own rules stay", + "handSome": "The {{count}} rules made by hand are left exactly as they are.", + "handNone": "Rules you add by hand later are never touched by this guide.", + "overTitle": "When a limit is reached", + "over": "Incoming: the other side is told to try again later. Outgoing: the delivery waits in the queue and goes out when the rate allows. Nothing is lost.", + "now": "Now: {{value}}", + "apply": "Save these limits", + "undoTitle": "Changing your mind", + "undo": "Run the guide again, or switch any rule off in its list. Rules the guide wrote start with “Preset:”.", + "nothing": "These limits are already in place. Nothing changes.", + "willChange": "What will change", + "add": "Add: {{sentence}}", + "update": "Change: {{sentence}}", + "remove": "Remove the preset rule for {{name}}.", + "done": "The limits are in place.", + "doneHint": "Each list shows its rules in plain words:", + "inbound": "incoming", + "outbound": "outgoing", + "quotas": "queue quotas", + "tabTitle": "Limits" + }, + "limitsSummary": { + "chooseTitle": "Sending and receiving limits", + "guidedHint": "Say who uses this server and get five sensible limits, each shown as a sentence you can adjust.", + "manualHint": "Add and edit each rule yourself, with every option.", + "none": "No limits here yet: nothing is slowed down or refused.", + "title": "In plain words", + "presets": "Use presets" + }, + "lock": { + "bannerTitle": "Locked: receives mail, can’t sign in, sends nothing on its own.", + "bannerDelegates": "Handed to", + "unlockAction": "Unlock…", + "lockThis": "Lock this account…", + "unavailable": "This server can’t lock accounts.", + "title": "Locked accounts", + "subtitle": "Accounts that keep receiving mail but can’t sign in and send nothing on their own, for someone who left, an investigation or a compromised account. Delegates can open them beside their own mail.", + "lockAction": "Lock an account…", + "none": "No account is locked.", + "lockedBy": "Locked {{when}} by {{who}}", + "editAction": "Delegates…", + "noDelegates": "Not handed to anyone.", + "sendsAs": "sends as", + "untilDay": "until {{day}}", + "access": { + "readHint": "Sees everything, changes nothing. Reading doesn’t even mark mail read.", + "organizeHint": "Also flags, files and moves mail and makes folders. Never deletes.", + "fullHint": "Everything the owner could do, deleting included.", + "read": "Read only", + "organize": "Read and organize", + "full": "Full" + }, + "reason": "Reason", + "reasonHint": "Why, for the audit log: a ticket, a case, “left the company”…", + "delegatesHint": "People who can open this account, beside their own mail, while it is locked. Everything they do there is recorded.", + "delegateAccount": "Choose a person", + "removeDelegate": "Remove", + "accessLabel": "Access", + "until": "Until (optional)", + "sendAs": "May send as this account (its Sent keeps a copy)", + "addDelegate": "Add a delegate", + "updated": "Delegates changed", + "locked": "Account locked. Its open sessions were ended.", + "updateFailed": "Nothing changed", + "lockFailed": "The account wasn’t locked", + "editTitle": "Delegates of {{name}}", + "lockTitle": "Lock an account", + "lockBody": "Mail keeps arriving, but nobody can sign in to the account, by any means, and it sends nothing on its own: no forwarding, no vacation reply, no read receipts. Open sessions end now.", + "account": "Account", + "chooseAccount": "Choose the account to lock", + "cancel": "Cancel", + "save": "Save", + "lockGo": "Lock account", + "unlocked": "Account unlocked", + "unlockFailed": "The account is still locked", + "unlockTitle": "Unlock {{name}}", + "unlockBody": "The account can sign in again and its forwarding and vacation reply resume. Delegates lose it, and any shares they had before get back what they were. The password is unchanged: if someone else may know it, set a new one on the account’s page too.", + "unlockGo": "Unlock", + "lockedBadge": "Locked" + }, + "metricsConnect": { + "copied": "Copied", + "copy": "Copy", + "failed": "Not saved", + "checking": "Checking…", + "needsLogin": "It answers, and asks for the user name and password: as it should.", + "works": "It answers with {{count}} measurements.", + "status": "It answered {{status}}.", + "cantCheck": "The browser couldn’t read it (usually cross-site rules). Check the target on your Prometheus instead.", + "title": "Where the server’s numbers go", + "save": "Save changes", + "prom": "Let Prometheus collect them", + "promHint": "Prometheus fetches them from this server every so often.", + "user": "User name", + "newPassword": "Make another password", + "replacePassword": "Replace the password", + "makePassword": "Make a password", + "open": "Without a user name anyone who can reach the server can read these numbers, which show how busy it is.", + "shownOnce": "The new password is in the scrape job below. It’s shown only now: copy it before you leave.", + "address": "Address:", + "check": "Check it answers", + "otel": "Send them to an OpenTelemetry service", + "off": "Don’t send them", + "collectorHttp": "Your own collector (HTTP)", + "collectorGrpc": "Your own collector (gRPC)", + "hcKey": "API key", + "keep": "Leave empty to keep the saved one", + "hcKeyHint": "An ingest key from your Honeycomb environment’s API keys.", + "hcDataset": "Dataset", + "gcEndpoint": "OTLP endpoint", + "gcEndpointHint": "From your stack’s OpenTelemetry page in the Grafana Cloud portal.", + "gcInstance": "Instance ID", + "gcToken": "Token", + "gcTokenHint": "An access policy token with metrics:write. Needed again whenever these settings change.", + "delta": "This server sends counters as deltas. If some counters don’t show up, put an OpenTelemetry Collector with the deltatocumulative processor in between.", + "collectorUrl": "Collector address", + "collectorHttpHint": "/v1/metrics is added if it isn’t there.", + "pushEvery": "The server sends them once a minute.", + "more": "Which metrics, and every setting on this page" + }, + "ports": { + "smtp": "Receiving mail from other servers", + "submission": "Sending from mail apps", + "submissions": "Sending from mail apps", + "https": "Webmail, this console, JMAP apps, automatic setup", + "http": "Redirect to HTTPS, and HTTP certificate checks", + "imaps": "Reading mail in IMAP apps", + "imap": "Reading mail in IMAP apps", + "pop3s": "Downloading mail with POP3", + "pop3": "Downloading mail with POP3", + "sieve": "Editing filters from mail apps (ManageSieve)", + "failed": "Not changed", + "open": "open", + "closed": "closed", + "onHardening": "Switched on Hardening", + "confirmOff": "Close it", + "close": "Close", + "openIt": "Open", + "title": "The standard ports", + "port": "Port", + "for": "For", + "encryption": "Encryption", + "state": "State", + "custom": "Other listeners", + "customLine": "{{name}}: {{protocol}} on port {{ports}}, {{tls}}.", + "reach": "Open here means the server listens. Whether the internet can reach it also depends on your firewall and hosting provider." + }, + "reachability": { + "title": "Reachable from outside", + "clusterBody": "Each server tries the others’ ports every {{minutes}} minutes, over the internet, by their public names.", + "since": "Oldest result: {{time}}.", + "localBody": "A single server can’t see its own firewall from inside, so this only says whether each port is listening here. To check from outside, try from another network: nc -vz .", + "none": "No listeners to check.", + "listening": "listening on this server", + "notListening": "not listening on this server" + }, + "reports": { + "more": "Names, subjects and every setting on this page", + "hourly": "every hour", + "daily": "once a day", + "weekly": "once a week", + "never": "never", + "failed": "Not saved", + "byRule": "by a rule on its page", + "title": "Reports, in short", + "save": "Save changes", + "outgoing": "Reports this server sends", + "dmarcSentence": "Tell other domains how mail claiming to be from them fared here (DMARC)", + "tlsSentence": "Tell them whether their mail reached us encrypted (TLS)", + "failureSentence": "Report each message that fails their checks (DMARC, DKIM, SPF), at most once a day per address", + "mixed": "Set differently on the DMARC, DKIM and SPF pages. Switching here sets all three.", + "privacy": "These include the failing message’s headers, which carry people’s addresses. Most large providers don’t send them.", + "fromSentence": "Send them, and bounce messages, from addresses at", + "defaultDomain": "the default domain ({{name}})", + "fromExample": "For example noreply-dmarc@{{domain}} and MAILER-DAEMON@{{domain}}.", + "incoming": "Reports other servers send you", + "inboundSentence": "Read reports sent to", + "inboundAfter": "and show them under Management › Reports", + "inboundHint": "Separate addresses with commas. * matches any domain: postmaster@* is every domain’s postmaster.", + "forwardSentence": "Then deliver them to that mailbox as well" + }, + "retention": { + "more": "Schedules and every setting on this page", + "mailboxes": "In people’s mailboxes", + "recovery": "Getting deleted things back", + "records": "The server’s own records", + "saved": "Saved", + "failed": "Not saved", + "daysWord": "days", + "days": "days", + "hours": "hours", + "setTime": "set a time", + "turnOff": "turn off", + "keepForever": "keep forever", + "never": "never", + "unsaved": "unsaved", + "default": "Default: {{value}}.", + "title": "How long things are kept", + "save": "Save {{count}} changes", + "holds": "Mail under a legal hold is kept whatever these say: deleting it archives it, and it doesn’t expire while held." + }, + "senderLevels": { + "relaxed": "Relaxed", + "recommended": "Recommended", + "strict": "Strict", + "relaxedHint": "Checks everything and hands the results to the spam filter, but refuses nothing. The server’s stock setting.", + "recommendedHint": "Also refuses mail forged in the name of a domain that asks for that (DMARC p=reject), and checks forwarding chains (ARC).", + "strictHint": "Also refuses senders without matching reverse DNS or a passing SPF record. Expect to lose some real mail from small or badly set-up servers.", + "spfEhlo": "Sending server’s name (SPF on EHLO)", + "spfFrom": "Envelope sender (SPF on MAIL FROM)", + "dkim": "Signatures (DKIM)", + "dmarc": "Sender domain’s policy (DMARC)", + "arc": "Forwarding chain (ARC)", + "iprev": "Reverse DNS of the sending server", + "off": "Not checked", + "checked": "Checked, scored", + "dmarcReject": "Refused if the domain says reject", + "refused": "Failures refused", + "failed": "The level could not be saved.", + "title": "How strict to be with incoming mail", + "byHand": "Set by hand below", + "now": "Current", + "check": "Check", + "footnote": "Checks run on port 25, where other servers deliver; mail from your own signed-in users isn’t checked. No level refuses unsigned mail: most legitimate mail would bounce.", + "replaces": "This replaces the six checks below, including any conditions you added to them. DKIM signing isn’t touched.", + "apply": "Use {{level}}" + }, + "sendingCard": { + "chooseTitle": "How this server sends mail", + "guidedHint": "Check port 25, choose direct delivery or a relay service, and see exactly what changes. About three minutes.", + "manualHint": "Edit the delivery strategy and routes yourself, with every option.", + "title": "Direct delivery or a relay?", + "hint": "A guide checks whether port 25 is open and sets up Amazon SES, Mailgun, SendGrid or another relay for you.", + "start": "Set it up" + }, + "sendingSetup": { + "stepHow": "How mail leaves", + "stepService": "Relay service", + "stepSignIn": "Sign-in", + "stepReview": "Review", + "stepDone": "Test it", + "title": "How this server sends mail", + "subtitle": "Deliver straight to other mail servers, or hand outgoing mail to a relay service that delivers it for you.", + "whyTitle": "Which one?", + "whyDirect": "Direct delivery needs port 25 open to the internet, an IP address with a clean reputation, and reverse DNS that names this server.", + "whyRelay": "A relay suits a home connection or a cloud host that blocks port 25, or when you’d rather someone else handle reputation. It costs a little and the service sees your outgoing mail.", + "undoTitle": "Changing your mind", + "undo": "Run this guide again and pick the other way. A relay you set up stays saved, unused, so switching back is quick.", + "nowDirect": "Right now this server delivers directly.", + "nowRelay": "Right now outgoing mail goes through a relay: {{host}}.", + "nowOther": "Right now outgoing mail follows a custom rule ({{target}}). Choosing here replaces that fallback.", + "checkTitle": "Check port 25 first", + "checkHint": "Connects from this server to a big mail provider’s servers on port 25 and waits for their greeting. Nothing is sent.", + "checkTarget": "Domain to try", + "checkRun": "Check", + "checkOpen": "Port 25 is open: {{host}} answered. Direct delivery can work.", + "checkBlocked": "Port 25 looks blocked ({{reason}}). Your host probably filters it: use a relay, or ask them to open it.", + "checkUnknown": "The check couldn’t tell: {{reason}}", + "direct": "Deliver directly", + "recommended": "Recommended", + "directHint": "This server looks up each recipient’s mail servers and delivers to them itself. No third party, nothing to pay.", + "relay": "Through a relay service", + "relayHint": "Amazon SES, Mailgun, SendGrid, Postmark, Brevo, SMTP2GO or your ISP’s server. It delivers for you and handles reputation.", + "beforeTitle": "Before this works", + "otherHint": "Your ISP’s or provider’s outgoing server.", + "byRegion": "Server by region · port {{port}}", + "region": "Region", + "regionHint": "The region your account sends from.", + "host": "Server", + "port": "Port", + "encryption": "Encryption", + "implicit": "Encrypted from the start (usually port 465)", + "starttls": "STARTTLS, required (usually port 587)", + "storedTitle": "Where it’s kept", + "stored": "The password is saved in this server’s settings, like other secrets, and is never shown again here.", + "username": "User name", + "password": "Password", + "keepPassword": "Leave empty to keep the saved one", + "apply": "Make the change", + "ifFails": "If something fails", + "rollback": "The changes are written one at a time. If one is refused, the ones before it are put back, so a failed attempt leaves things as they were.", + "nothing": "This server already sends mail this way. Nothing needs to change.", + "willChange": "What will change", + "kept": "Left as they are", + "keptHint": "Your {{count}} other routing rules still apply first, before this fallback.", + "whereTitle": "Where to look later", + "where": "Settings › Mail flow › Sending holds the delivery strategy and routes this guide changed, for anything it doesn’t cover.", + "doneRelay": "Outgoing mail now goes through {{host}}.", + "doneDirect": "This server delivers outgoing mail directly.", + "test1": "From the webmail, send a message to an address outside your domains, such as a personal Gmail or Outlook account.", + "test2": "Watch it leave in", + "test2Link": "Emails › History › Outbound delivery", + "test2b": ". A delivery lists each attempt and the answer from the other side.", + "test3": "If it waits in", + "test3Link": "Emails › Queued", + "test3Relay": ", its last error says why: usually the sign-in, or a sender domain the relay hasn’t verified yet.", + "test3Direct": ", its last error says why: often port 25 being blocked, or the other side rejecting this server’s IP.", + "tabTitle": "Sending mail" + }, + "settingsOverview": { + "setUp": "Set up", + "title": "Settings", + "subtitle": "The menu above groups everything the server does by job. Anything you change is marked against its default.", + "guided": "Guided setup", + "changed": "Changed from default", + "changedCount": "{{count}} changed" + }, + "spamLevels": { + "relaxed": "Relaxed", + "balanced": "Balanced", + "strict": "Strict", + "relaxedHint": "Only clear spam goes to Junk. Some spam reaches the inbox; almost nothing real is misfiled.", + "balancedHint": "The server’s stock setting. Likely spam goes to Junk; nothing is refused.", + "strictHint": "More goes to Junk, and blatant spam is refused outright. The sender of a misjudged message gets a bounce.", + "failed": "Not saved", + "title": "How hard the spam filter is", + "byHand": "Thresholds set by hand below", + "save": "Save changes", + "enable": "Filter incoming mail for spam", + "now": "Current", + "discardWarning": "A discard threshold is set by hand. Picking a level turns it off: deleted mail can’t be found again, and nobody is told.", + "trustReplies": "Never treat replies to people’s own messages as spam", + "trustContacts": "Never treat mail from people in their address book as spam", + "pyzor": "Check messages against Pyzor’s shared spam list", + "pyzorHint": "Sends a fingerprint of each message, not the message itself, to public.pyzor.org.", + "more": "More:", + "localAi": "Local AI", + "blocklists": "Blocklists", + "scores": "Scores", + "trusted": "Trusted domains" + }, + "help": { + "page": "Help for this page", + "label": "Help", + "tasks": "What people do here", + "options": "The options on this page", + "manual": "Read more in the admin manual", + "about": "About this option", + "learnMore": "Learn more" + }, + "security": { + "fixed": "Fixed: {{what}}", + "undoFailed": "Not undone", + "undo": "Undo", + "fixFailed": "Not fixed", + "acceptFailed": "Not accepted", + "removeFailed": "Not removed", + "critical": "Critical", + "important": "Important", + "good": "Good practice", + "fix": "Fix", + "review": "Review", + "toSwitch": "Go to the switch", + "accept": "Accept the risk…", + "title": "Security", + "subtitle": "Settings that leave this server more open than it needs to be, most serious first. Checked when this page opens.", + "countsCritical": "{{all}} to do, {{critical}} of them critical", + "counts": "{{all}} to do", + "staleAccepted": "Accepted earlier for a different value, by {{who}}: “{{note}}”", + "removeOld": "Remove the old acceptance", + "noteLabel": "Why is this accepted?", + "notePlaceholder": "Every administrator will see this note.", + "acceptConfirm": "Accept", + "accepted": "Accepted", + "acceptedBy": "{{who}}, {{when}}", + "unaccept": "Put back on the list", + "passed": "Passed checks ({{count}})", + "notCheckedCount": ", {{count}} not checked", + "notChecked": "— not checked: {{why}}" } } diff --git a/tools/i18n-extract.mjs b/tools/i18n-extract.mjs new file mode 100644 index 0000000..ae911c4 --- /dev/null +++ b/tools/i18n-extract.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * inbuxa: keeps src/i18n/en.json the whole English catalog. The console's + * strings are written inline, `t('key', 'English')`, so a page reads well in + * code; a translation needs them in one file. This finds every such call and + * adds the ones en.json lacks, with their English. It never changes a string + * en.json already has: that one is what people see. + * + * node tools/i18n-extract.mjs add what's missing + * node tools/i18n-extract.mjs --check fail if anything is missing (CI) + * + * Understood: t('k', 'text'), t('k', 'text', {…}), t('k', {defaultValue}), + * and plurals, t('k', {count, defaultValue_one, defaultValue_other}), which + * become k_one and k_other. A key built at run time can't be read and is + * skipped; so is a call with no English, which en.json must already hold. + */ + +import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import ts from 'typescript'; + +const root = new URL('..', import.meta.url).pathname; +const srcDir = join(root, 'src'); +const catalogPath = join(srcDir, 'i18n', 'en.json'); +const check = process.argv.includes('--check'); + +function files(dir) { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return files(path); + return /\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name) ? [path] : []; + }); +} + +const text = (node) => + node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : undefined; + +function objectStrings(node) { + const out = {}; + if (!node || !ts.isObjectLiteralExpression(node)) return out; + for (const p of node.properties) { + if (!ts.isPropertyAssignment(p)) continue; + const name = p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) ? p.name.text : undefined; + const value = text(p.initializer); + if (name && value !== undefined) out[name] = value; + } + return out; +} + +/** Every key the code gives English for: key -> { text, where }. */ +export function extract() { + const found = new Map(); + const add = (key, value, where) => { + const seen = found.get(key); + if (!seen) found.set(key, { text: value, where }); + }; + for (const path of files(srcDir)) { + const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true); + const visit = (node) => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + const isT = + (ts.isIdentifier(callee) && callee.text === 't') || + (ts.isPropertyAccessExpression(callee) && callee.name.text === 't'); + const key = isT ? text(node.arguments[0]) : undefined; + if (key) { + const where = `${relative(root, path)}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}`; + const second = node.arguments[1]; + const direct = text(second); + if (direct !== undefined) { + add(key, direct, where); + } else { + const o = objectStrings(second); + if (o.defaultValue !== undefined) add(key, o.defaultValue, where); + for (const [k, v] of Object.entries(o)) { + const m = /^defaultValue_(zero|one|two|few|many|other)$/.exec(k); + if (m) add(`${key}_${m[1]}`, v, where); + } + } + } + } + ts.forEachChild(node, visit); + }; + visit(source); + } + return found; +} + +function lookup(catalog, key) { + let at = catalog; + for (const part of key.split('.')) { + if (at === null || typeof at !== 'object' || !(part in at)) return undefined; + at = at[part]; + } + return at; +} + +/** Puts a string at a dotted key; false if a parent is already a string. */ +function place(catalog, key, value) { + const parts = key.split('.'); + let at = catalog; + for (const part of parts.slice(0, -1)) { + if (!(part in at)) at[part] = {}; + if (typeof at[part] !== 'object' || at[part] === null) return false; + at = at[part]; + } + const last = parts[parts.length - 1]; + if (typeof at[last] === 'object' && at[last] !== null) return false; + at[last] = value; + return true; +} + + +const catalog = JSON.parse(readFileSync(catalogPath, 'utf8')); +const missing = []; +const clashes = []; +for (const [key, { text: value, where }] of extract()) { + if (lookup(catalog, key) !== undefined) continue; + missing.push({ key, where }); + if (!check && !place(catalog, key, value)) clashes.push({ key, where }); +} + +if (check) { + if (missing.length > 0) { + for (const m of missing.slice(0, 20)) console.error(`missing from en.json: ${m.key} (${m.where})`); + if (missing.length > 20) console.error(`…and ${missing.length - 20} more`); + console.error('Run: node tools/i18n-extract.mjs'); + process.exit(1); + } + console.log('en.json has every string'); +} else { + writeFileSync(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); + console.log(`added ${missing.length - clashes.length} strings to en.json`); + for (const c of clashes) console.error(`not added, the key is also a group: ${c.key} (${c.where})`); + if (clashes.length > 0) process.exit(1); +}
{t('audit.change.field', 'Setting')}{t('audit.change.before', 'Before')}{t('audit.change.after', 'After')}{t('audit.changes.field', 'Setting')}{t('audit.changes.before', 'Before')}{t('audit.changes.after', 'After')}
{change.field} - {t('audit.change.secret', 'Changed. Secrets are never recorded.')} + {t('audit.changes.secret', 'Changed. Secrets are never recorded.')}