diff --git a/create-a-container/client/src/app/Sidebar.tsx b/create-a-container/client/src/app/Sidebar.tsx index bf6e4b17..d888878e 100644 --- a/create-a-container/client/src/app/Sidebar.tsx +++ b/create-a-container/client/src/app/Sidebar.tsx @@ -20,6 +20,7 @@ import { Container as ContainerIcon, Globe, KeyRound, + Mail, Radio, Server, Settings, @@ -61,6 +62,7 @@ const ADMIN: NavLink[] = [ }, { to: '/agents', label: 'Agents', icon: , adminOnly: true }, { to: '/apikeys', label: 'API Keys', icon: }, + { to: '/mail-accounts', label: 'Email', icon: }, { to: '/settings', label: 'Settings', icon: , adminOnly: true }, ]; diff --git a/create-a-container/client/src/app/router.tsx b/create-a-container/client/src/app/router.tsx index a0450d92..46282262 100644 --- a/create-a-container/client/src/app/router.tsx +++ b/create-a-container/client/src/app/router.tsx @@ -25,6 +25,7 @@ import { InviteUserPage } from '@/pages/users/InviteUserPage'; import { GroupsListPage } from '@/pages/groups/GroupsListPage'; import { GroupFormPage } from '@/pages/groups/GroupFormPage'; import { ApiKeysListPage } from '@/pages/apikeys/ApiKeysListPage'; +import { MailAccountsListPage } from '@/pages/mail-accounts/MailAccountsListPage'; import { SettingsPage } from '@/pages/settings/SettingsPage'; import { ResourceRequestsPage } from '@/pages/resource-requests/ResourceRequestsPage'; import { MyRequestsPage } from '@/pages/resource-requests/MyRequestsPage'; @@ -84,6 +85,8 @@ export const router = createBrowserRouter([ { path: '/apikeys', element: }, + { path: '/mail-accounts', element: }, + { path: '/settings', element: }, { path: '/resource-requests', element: }, diff --git a/create-a-container/client/src/lib/queries.ts b/create-a-container/client/src/lib/queries.ts index da4d006e..30a886a0 100644 --- a/create-a-container/client/src/lib/queries.ts +++ b/create-a-container/client/src/lib/queries.ts @@ -15,6 +15,12 @@ import type { Group, Job, JobStatusRow, + MailAccount, + MailDomain, + MailDnsInfo, + MailHostInfo, + MailPtrInfo, + MailSuppression, Node, NodeStats, AppSettings, @@ -49,6 +55,11 @@ export const keys = { groups: () => ['groups'] as const, group: (id: number | string) => ['groups', String(id)] as const, apikeys: () => ['apikeys'] as const, + mailAccounts: () => ['mail-accounts'] as const, + mailDomains: () => ['mail-accounts', 'domains'] as const, + mailSuppressions: (id: string) => ['mail-accounts', id, 'suppressions'] as const, + mailDns: (domainId: number | string) => ['external-domains', String(domainId), 'mail-dns'] as const, + mailHost: () => ['mail', 'host'] as const, settings: () => ['settings'] as const, job: (id: number | string) => ['jobs', String(id)] as const, jobStatuses: (id: number | string) => ['jobs', String(id), 'statuses'] as const, @@ -142,6 +153,16 @@ export const queries = { // API keys listApiKeys: () => api.get('/api/v1/apikeys'), + // Mail accounts (issue #67) + listMailAccounts: () => api.get('/api/v1/mail-accounts'), + listMailDomains: () => api.get('/api/v1/mail-accounts/domains'), + listMailSuppressions: (id: string) => + api.get(`/api/v1/mail-accounts/${id}/suppressions`), + getDomainMailDns: (domainId: number | string) => + api.get(`/api/v1/external-domains/${domainId}/mail/dns`), + getMailHost: () => api.get('/api/v1/agents/mail-host'), + getMailPtr: () => api.get('/api/v1/agents/mail-host/ptr'), + // Settings getSettings: () => api.get('/api/v1/settings'), diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index 3e391095..063ecaa2 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -156,6 +156,14 @@ export interface Agent { hostname: string; ipv4Address: string | null; services: Record | null; + /** Service groups the agent runs (AGENT_SERVICES), e.g. ['nginx','dnsmasq','mail']. */ + enabledServices: string[] | null; + /** Binaries missing for the enabled groups; blocks the mail-host claim. */ + missingBinaries: string[] | null; + /** Non-null on the single agent holding the mail-host claim. */ + mailHostSince: string | null; + isLocal: boolean; + hasApiKeyPin: boolean; lastCheckinAt: string | null; /** Server-computed, so it is immune to client clock drift. */ secondsSinceCheckin: number | null; @@ -194,6 +202,11 @@ export interface ExternalDomain { site: { id: number; name: string } | null; authServer: string | null; hasCloudflareApiKey: boolean; + /** Read-only mail state (issue #67) — changed via the …/mail endpoints. */ + mailEnabled: boolean; + mailDnsVerified: boolean; + mailMxVerified: boolean; + mailDnsCheckedAt: string | null; } export interface ServiceHttp { @@ -353,6 +366,98 @@ export interface ApiKeyCreated extends ApiKey { warning: string; } +/** A domain mail accounts can be created on (mail enabled + DNS verified). */ +export interface MailDomain { + id: number; + name: string; +} + +export interface MailAccount { + id: string; + address: string | null; + localPart: string; + domain: { id: number; name: string; canSend: boolean; canReceive: boolean } | null; + owner: string | null; + description: string | null; + enabled: boolean; + quotaMb: number; + unsubscribeHeaders: boolean; + lastRotatedAt: string | null; + createdAt: string; + updatedAt: string; +} + +/** Create/rotate response — the password is returned exactly once. */ +export interface MailAccountSecret extends MailAccount { + password: string; + connection: { + host: string; + username: string; + smtp: { ports: number[] }; + imap: { ports: number[] }; + }; +} + +export interface MailSuppression { + id: number; + recipient: string; + source: 'one-click' | 'admin'; + createdAt: string; +} + +export interface MailDnsRecord { + type: 'TXT' | 'MX'; + name: string; + value: string; +} + +/** One gate of the stored Check DNS outcome. */ +export interface MailDnsGate { + pass: boolean; + [key: string]: unknown; +} + +export interface MailDnsCheckResult { + checkedAt: string; + spf: MailDnsGate; + dkim: MailDnsGate; + dmarc: MailDnsGate; + mx: MailDnsGate; + ptr?: { name: string | null; forwardConfirmed: boolean }; + warnings: string[]; +} + +export interface MailDnsInfo { + records: MailDnsRecord[]; + mailEnabled: boolean; + mailDnsVerified: boolean; + mailMxVerified: boolean; + lastCheck: { checkedAt: string; result: MailDnsCheckResult | null } | null; +} + +export interface MailHostInfo { + agent: { + id: number; + siteId: number; + siteName: string | null; + hostname: string; + mailHostSince: string; + enabledServices: string[] | null; + missingBinaries: string[] | null; + } | null; + mailIp: string | null; + mailHostname: string | null; + selfManagedSiteId: number | null; +} + +export interface MailPtrInfo { + mailIp: string | null; + ptr: string | null; + forwardConfirmed: boolean; + mailHostname: string | null; + suggestion: string | null; +} + export interface AppSettings { smtpUrl: string; smtpNoreplyAddress: string; @@ -363,6 +468,17 @@ export interface AppSettings { bannerMessage: string; /** Max PSI probes per usage report; '' uses the server default (16), '0' disables. */ usagePsiProbeLimit: string; + /** Mail service (issue #67). All stored as strings; '' means unset. */ + mailHostname: string; + mailRelayhost: string; + mailRelayhostUsername: string; + mailRelayhostPassword: string; + mailSpfInclude: string; + mailDnsCheckResolvers: string; + mailDbHost: string; + mailSelfManagedSiteId: string; + mailDefaultQuotaMb: string; + mailMessageSizeLimitMb: string; } export type ResourceType = 'memory' | 'swap' | 'cpus' | 'rootfs'; diff --git a/create-a-container/client/src/pages/agents/AgentRowActions.tsx b/create-a-container/client/src/pages/agents/AgentRowActions.tsx new file mode 100644 index 00000000..0b11d7f4 --- /dev/null +++ b/create-a-container/client/src/pages/agents/AgentRowActions.tsx @@ -0,0 +1,76 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button, useToast } from '@mieweb/ui'; +import { KeyRound, MailMinus } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys } from '@/lib/queries'; +import type { Agent } from '@/lib/types'; + +/** Admin actions on one agent row: release the mail-host claim (holder only) + * and clear the API-key pin (remote agents re-pin on their next check-in). */ +export function AgentRowActions({ agent }: { agent: Agent }) { + const qc = useQueryClient(); + const toast = useToast(); + const invalidate = () => { + qc.invalidateQueries({ queryKey: keys.agents() }); + qc.invalidateQueries({ queryKey: keys.mailHost() }); + }; + + const release = useMutation({ + mutationFn: () => api.delete('/api/v1/agents/mail-host'), + onSuccess: () => { + toast.success('Mail-host claim released — the next qualifying check-in takes it'); + invalidate(); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + const clearPin = useMutation({ + mutationFn: () => api.delete(`/api/v1/agents/${agent.id}/api-key-pin`), + onSuccess: () => { + toast.success('API-key pin cleared'); + invalidate(); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + if (!agent.mailHostSince && !agent.hasApiKeyPin) { + return —; + } + + return ( +
+ {agent.mailHostSince && ( + + )} + {agent.hasApiKeyPin && ( + + )} +
+ ); +} diff --git a/create-a-container/client/src/pages/agents/AgentsListPage.tsx b/create-a-container/client/src/pages/agents/AgentsListPage.tsx index b7a04c01..f1505958 100644 --- a/create-a-container/client/src/pages/agents/AgentsListPage.tsx +++ b/create-a-container/client/src/pages/agents/AgentsListPage.tsx @@ -18,6 +18,8 @@ import { keys, queries } from '@/lib/queries'; import type { Agent } from '@/lib/types'; import { useDocumentTitle } from '@/lib/useDocumentTitle'; import { AgentServiceBadges } from './AgentServiceBadges'; +import { AgentRowActions } from './AgentRowActions'; +import { MailHostBadge } from './MailHostBadge'; import { OnlineBadge } from './OnlineBadge'; export function AgentsListPage() { @@ -65,7 +67,9 @@ export function AgentsListPage() { IPv4 Status Services + Mail Last check-in + Actions @@ -80,7 +84,13 @@ export function AgentsListPage() { + + + {formatRelativeTime(agent.lastCheckinAt, agent.secondsSinceCheckin)} + + + ))} diff --git a/create-a-container/client/src/pages/agents/MailHostBadge.tsx b/create-a-container/client/src/pages/agents/MailHostBadge.tsx new file mode 100644 index 00000000..3aa4f6fc --- /dev/null +++ b/create-a-container/client/src/pages/agents/MailHostBadge.tsx @@ -0,0 +1,28 @@ +import { Badge } from '@mieweb/ui'; +import type { Agent } from '@/lib/types'; + +/** Mail-host claim state for one agent row. */ +export function MailHostBadge({ agent }: { agent: Agent }) { + if (agent.mailHostSince) { + return ( + + Mail host + + ); + } + if (!agent.enabledServices?.includes('mail')) { + return —; + } + if (agent.missingBinaries && agent.missingBinaries.length > 0) { + return ( + + Missing packages + + ); + } + return ( + + Standby + + ); +} diff --git a/create-a-container/client/src/pages/external-domains/DomainMailSection.tsx b/create-a-container/client/src/pages/external-domains/DomainMailSection.tsx new file mode 100644 index 00000000..28837854 --- /dev/null +++ b/create-a-container/client/src/pages/external-domains/DomainMailSection.tsx @@ -0,0 +1,207 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, AlertDescription, Badge, Button, Spinner, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, useToast, +} from '@mieweb/ui'; +import { Copy, MailCheck, RefreshCw } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import type { ExternalDomain, MailDnsCheckResult } from '@/lib/types'; + +const GATES: { key: keyof Pick; label: string }[] = [ + { key: 'spf', label: 'SPF' }, + { key: 'dkim', label: 'DKIM' }, + { key: 'dmarc', label: 'DMARC' }, + { key: 'mx', label: 'MX' }, +]; + +/** Mail enablement for one external domain: records to publish (with copy + * buttons), Check DNS, enable/disable, and send/receive badges with reasons. */ +export function DomainMailSection({ domain }: { domain: ExternalDomain }) { + const qc = useQueryClient(); + const toast = useToast(); + const { data, isLoading } = useQuery({ + queryKey: keys.mailDns(domain.id), + queryFn: () => queries.getDomainMailDns(domain.id), + }); + const { data: host } = useQuery({ queryKey: keys.mailHost(), queryFn: queries.getMailHost }); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: keys.mailDns(domain.id) }); + qc.invalidateQueries({ queryKey: keys.externalDomain(domain.id) }); + qc.invalidateQueries({ queryKey: keys.externalDomains() }); + }; + + const action = useMutation({ + // Enable/disable is a PATCH on the domain itself; enabling is gated on + // Check DNS server-side. check-dns stays a dedicated action endpoint. + mutationFn: (verb: 'check-dns' | 'enable' | 'disable') => + verb === 'check-dns' + ? api.post(`/api/v1/external-domains/${domain.id}/mail/check-dns`) + : api.patch(`/api/v1/external-domains/${domain.id}`, { mailEnabled: verb === 'enable' }), + onSuccess: (_data, verb) => { + toast.success( + verb === 'check-dns' ? 'DNS re-checked' : verb === 'enable' ? 'Mail enabled' : 'Mail disabled', + ); + invalidate(); + }, + onError: (err: ApiError) => { + toast.error(err.message); + invalidate(); // check-dns ran even when enable was refused + }, + }); + + const copy = (value: string) => { + navigator.clipboard.writeText(value).catch(() => undefined); + toast.success('Copied to clipboard'); + }; + + if (isLoading || !data) { + return ( +
+ +
+ ); + } + + const canSend = data.mailEnabled && data.mailDnsVerified; + const canReceive = data.mailEnabled && data.mailMxVerified; + const check = data.lastCheck?.result ?? null; + const noMailHost = host && !host.agent && !host.selfManagedSiteId; + + return ( +
+
+

+ Mail +

+
+ + {canSend ? 'Can send' : "Can't send"} + + + {canReceive ? 'Can receive' : "Can't receive"} + +
+
+ + {noMailHost && ( + + + No agent currently runs the mail service and no self-managed site is configured — mail + can be prepared here but nothing will deliver it. See the Agents page. + + + )} + +

+ Publish these DNS records, then run Check DNS. Sending needs SPF, DKIM, and DMARC; + receiving needs the MX record. +

+ + + + Type + Name + Value + + Copy + + + + + {data.records.map((r) => ( + + {r.type} + {r.name} + + {r.value} + + + + + + ))} + +
+ + {check && ( +
+ + Last check {new Date(check.checkedAt).toLocaleString()}: + + {GATES.map(({ key, label }) => ( + + {label} + + ))} +
+ )} + {check && check.warnings.length > 0 && ( + + +
    + {check.warnings.map((w) => ( +
  • {w}
  • + ))} +
+
+
+ )} + +
+ + {data.mailEnabled ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx index a3f1cf14..8a135f34 100644 --- a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx +++ b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx @@ -17,6 +17,7 @@ import { Globe } from 'lucide-react'; import { api, ApiError } from '@/lib/api'; import { keys, queries } from '@/lib/queries'; import { FormPageLayout } from '@/components/FormPageLayout'; +import { DomainMailSection } from './DomainMailSection'; import type { ExternalDomain } from '@/lib/types'; const schema = z.object({ @@ -176,6 +177,8 @@ export function ExternalDomainFormPage() { {(mutation.error as ApiError).message} )} + {/* Mail enablement mutates via its own endpoints — not part of the PUT form. */} + {isEdit && domain && } ); diff --git a/create-a-container/client/src/pages/mail-accounts/MailAccountsListPage.tsx b/create-a-container/client/src/pages/mail-accounts/MailAccountsListPage.tsx new file mode 100644 index 00000000..76046068 --- /dev/null +++ b/create-a-container/client/src/pages/mail-accounts/MailAccountsListPage.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, AlertDescription, Badge, Button, PageHeader, Spinner, Switch, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, useToast, +} from '@mieweb/ui'; +import { Mail, MailX, Plus, RefreshCw, Trash2 } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import type { MailAccount, MailAccountSecret } from '@/lib/types'; +import { useDocumentTitle } from '@/lib/useDocumentTitle'; +import { MailCredentialsAlert } from './MailCredentialsAlert'; +import { NewMailAccountForm } from './NewMailAccountForm'; +import { SuppressionsModal } from './SuppressionsModal'; + +export function MailAccountsListPage() { + useDocumentTitle('Email accounts'); + const { data, isLoading, error } = useQuery({ + queryKey: keys.mailAccounts(), + queryFn: queries.listMailAccounts, + }); + const qc = useQueryClient(); + const toast = useToast(); + const [creating, setCreating] = useState(false); + const [created, setCreated] = useState(null); + const [suppressionsFor, setSuppressionsFor] = useState(null); + + const invalidate = () => qc.invalidateQueries({ queryKey: keys.mailAccounts() }); + + const patch = useMutation({ + mutationFn: ({ id, body }: { id: string; body: Record }) => + api.patch(`/api/v1/mail-accounts/${id}`, body), + onSuccess: invalidate, + onError: (err: ApiError) => toast.error(err.message), + }); + + const rotate = useMutation({ + mutationFn: (id: string) => + api.post(`/api/v1/mail-accounts/${id}/rotate-password`), + onSuccess: (secret) => { + setCreated(secret); + invalidate(); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + const del = useMutation({ + mutationFn: (id: string) => api.delete(`/api/v1/mail-accounts/${id}`), + onSuccess: () => { + toast.success('Email account deleted'); + invalidate(); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + return ( +
+ } + actions={ + !creating && ( + + ) + } + /> + + {created && setCreated(null)} />} + + {creating && ( + { + setCreated(secret); + setCreating(false); + }} + onCancel={() => setCreating(false)} + /> + )} + + {error && ( + + {(error as ApiError).message} + + )} + {isLoading && ( +
+ +
+ )} + + {data && data.length === 0 && !creating && ( + + + No email accounts yet. Create one to send via SMTP (ports 587/465) and read replies + via IMAP (ports 993/143) on an enabled domain. + + + )} + + {data && data.length > 0 && ( + + + + Address + Description + Status + Quota + Unsubscribe headers + Actions + + + + {data.map((a: MailAccount) => ( + + {a.address} + {a.description || '—'} + +
+ {a.enabled ? ( + Enabled + ) : ( + Disabled + )} + {a.domain && !a.domain.canSend && ( + + Domain can't send + + )} +
+
+ {a.quotaMb} MB + + + patch.mutate({ id: a.id, body: { unsubscribeHeaders: checked } }) + } + aria-label={`Toggle RFC 8058 unsubscribe headers for ${a.address}`} + /> + + +
+ + + + +
+
+
+ ))} +
+
+ )} + + {suppressionsFor && ( + setSuppressionsFor(null)} /> + )} +
+ ); +} diff --git a/create-a-container/client/src/pages/mail-accounts/MailCredentialsAlert.tsx b/create-a-container/client/src/pages/mail-accounts/MailCredentialsAlert.tsx new file mode 100644 index 00000000..7575c0c8 --- /dev/null +++ b/create-a-container/client/src/pages/mail-accounts/MailCredentialsAlert.tsx @@ -0,0 +1,57 @@ +import { Alert, AlertDescription, Button, useToast } from '@mieweb/ui'; +import type { MailAccountSecret } from '@/lib/types'; + +/** One-time password + connection settings shown after create/rotate. */ +export function MailCredentialsAlert({ + created, + onDismiss, +}: { + created: MailAccountSecret; + onDismiss: () => void; +}) { + const toast = useToast(); + const copy = (value: string) => { + navigator.clipboard.writeText(value).catch(() => undefined); + toast.success('Copied to clipboard'); + }; + return ( + + +
+ Save this password — it will not be shown again. +
+ + {created.password} + + +
+
+
Username
+
{created.connection.username}
+
Server
+
{created.connection.host}
+
SMTP
+
+ ports {created.connection.smtp.ports.join(', ')} (STARTTLS on 587, implicit TLS on 465) +
+
IMAP
+
ports {created.connection.imap.ports.join(', ')}
+
+
+ +
+
+
+
+ ); +} diff --git a/create-a-container/client/src/pages/mail-accounts/NewMailAccountForm.tsx b/create-a-container/client/src/pages/mail-accounts/NewMailAccountForm.tsx new file mode 100644 index 00000000..bb932f63 --- /dev/null +++ b/create-a-container/client/src/pages/mail-accounts/NewMailAccountForm.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Alert, AlertDescription, Button, Input, Select } from '@mieweb/ui'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import type { MailAccountSecret } from '@/lib/types'; + +/** Inline create form — accounts can only be created on can-send domains. */ +export function NewMailAccountForm({ + onCreated, + onCancel, +}: { + onCreated: (created: MailAccountSecret) => void; + onCancel: () => void; +}) { + const qc = useQueryClient(); + const { data: domains } = useQuery({ queryKey: keys.mailDomains(), queryFn: queries.listMailDomains }); + const [externalDomainId, setExternalDomainId] = useState(''); + const [localPart, setLocalPart] = useState(''); + const [description, setDescription] = useState(''); + + const create = useMutation({ + mutationFn: () => + api.post('/api/v1/mail-accounts', { + externalDomainId: parseInt(externalDomainId, 10), + localPart, + description: description || null, + }), + onSuccess: (created) => { + qc.invalidateQueries({ queryKey: keys.mailAccounts() }); + onCreated(created); + }, + }); + + if (domains && domains.length === 0) { + return ( + + + No domain can send mail yet. An administrator must enable mail on an external domain + (publish its DNS records and pass Check DNS) before accounts can be created. + + + ); + } + + const domainName = domains?.find((d) => String(d.id) === externalDomainId)?.name; + + return ( +
{ + e.preventDefault(); + create.mutate(); + }} + className="flex flex-col gap-3 rounded-lg border border-(--color-border,#e5e7eb) p-4" + > +
+ setLocalPart(e.target.value)} + helperText={domainName ? `Will become ${localPart || '
'}@${domainName}` : undefined} + required + /> +
+ setDescription(e.target.value)} + /> + {create.error && ( + + {(create.error as ApiError).message} + + )} +
+ + +
+
+ ); +} diff --git a/create-a-container/client/src/pages/mail-accounts/SuppressionsModal.tsx b/create-a-container/client/src/pages/mail-accounts/SuppressionsModal.tsx new file mode 100644 index 00000000..7477a702 --- /dev/null +++ b/create-a-container/client/src/pages/mail-accounts/SuppressionsModal.tsx @@ -0,0 +1,105 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, AlertDescription, Button, Modal, ModalBody, ModalHeader, ModalTitle, Spinner, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, useToast, +} from '@mieweb/ui'; +import { Trash2 } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { useSession } from '@/lib/auth'; +import { keys, queries } from '@/lib/queries'; +import type { MailAccount, MailSuppression } from '@/lib/types'; + +/** Recipients who one-click-unsubscribed from this account. Only admins can + * remove entries — owners must not quietly re-subscribe people. */ +export function SuppressionsModal({ + account, + onClose, +}: { + account: MailAccount; + onClose: () => void; +}) { + const { data: session } = useSession(); + const isAdmin = !!session?.isAdmin; + const qc = useQueryClient(); + const toast = useToast(); + const { data, isLoading, error } = useQuery({ + queryKey: keys.mailSuppressions(account.id), + queryFn: () => queries.listMailSuppressions(account.id), + }); + + const remove = useMutation({ + mutationFn: (suppressionId: number) => + api.delete(`/api/v1/mail-accounts/${account.id}/suppressions/${suppressionId}`), + onSuccess: () => { + toast.success('Suppression removed'); + qc.invalidateQueries({ queryKey: keys.mailSuppressions(account.id) }); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + return ( + !open && onClose()}> + + Unsubscribed recipients — {account.address} + + + {error && ( + + {(error as ApiError).message} + + )} + {isLoading && ( +
+ +
+ )} + {data && data.length === 0 && ( +

+ Nobody has unsubscribed from this account. +

+ )} + {data && data.length > 0 && ( + + + + Recipient + Source + Since + {isAdmin && ( + + Actions + + )} + + + + {data.map((s: MailSuppression) => ( + + {s.recipient} + {s.source} + {new Date(s.createdAt).toLocaleDateString()} + {isAdmin && ( + + + + )} + + ))} + +
+ )} +
+
+ ); +} diff --git a/create-a-container/client/src/pages/settings/SettingsPage.tsx b/create-a-container/client/src/pages/settings/SettingsPage.tsx index d01628d8..aa32783a 100644 --- a/create-a-container/client/src/pages/settings/SettingsPage.tsx +++ b/create-a-container/client/src/pages/settings/SettingsPage.tsx @@ -38,6 +38,19 @@ const schema = z.object({ netboxToken: z.string(), bannerMessage: z.string(), usagePsiProbeLimit: z.string().regex(/^\d*$/, 'Must be a non-negative whole number'), + mailHostname: z.string(), + mailRelayhost: z.string(), + mailRelayhostUsername: z.string(), + mailRelayhostPassword: z.string(), + mailSpfInclude: z.string(), + mailDnsCheckResolvers: z.string(), + mailDbHost: z.string(), + mailSelfManagedSiteId: z.string().regex(/^\d*$/, 'Must be a site id'), + mailDefaultQuotaMb: z.string().regex(/^\d*$/, 'Must be a whole number of MB'), + mailMessageSizeLimitMb: z.string().regex(/^\d*$/, 'Must be a whole number of MB'), +}).refine((v) => !v.mailRelayhost.trim() || !!v.mailSpfInclude.trim(), { + path: ['mailSpfInclude'], + message: 'Required when a relayhost is set — SPF must delegate to the relay', }); type FormData = z.infer; @@ -46,7 +59,7 @@ export function SettingsPage() { const toast = useToast(); const { data, isLoading, error } = useQuery({ queryKey: keys.settings(), queryFn: queries.getSettings }); - const { register, handleSubmit, reset, control, formState } = useForm({ + const { register, handleSubmit, reset, control, formState, setValue } = useForm({ resolver: zodResolver(schema), defaultValues: { smtpUrl: '', @@ -56,6 +69,16 @@ export function SettingsPage() { netboxToken: '', bannerMessage: '', usagePsiProbeLimit: '', + mailHostname: '', + mailRelayhost: '', + mailRelayhostUsername: '', + mailRelayhostPassword: '', + mailSpfInclude: '', + mailDnsCheckResolvers: '', + mailDbHost: '', + mailSelfManagedSiteId: '', + mailDefaultQuotaMb: '', + mailMessageSizeLimitMb: '', }, }); const { fields, append, remove } = useFieldArray({ control, name: 'defaultContainerEnvVars' }); @@ -76,6 +99,22 @@ export function SettingsPage() { onError: (err: ApiError) => toast.error(err.message), }); + // Suggest the forward-confirmed PTR of the mail IP as mail_hostname. + const suggestPtr = useMutation({ + mutationFn: queries.getMailPtr, + onSuccess: (ptr) => { + if (ptr.suggestion) { + setValue('mailHostname', ptr.suggestion, { shouldDirty: true }); + toast.success(`Suggested ${ptr.suggestion} from the PTR of ${ptr.mailIp}`); + } else if (!ptr.mailIp) { + toast.error('No mail IP yet — no agent holds the mail-host claim'); + } else { + toast.error(`No forward-confirmed PTR for ${ptr.mailIp}${ptr.ptr ? ` (found ${ptr.ptr})` : ''}`); + } + }, + onError: (err: ApiError) => toast.error(err.message), + }); + if (isLoading) return
; if (error) return {(error as ApiError).message}; @@ -94,6 +133,88 @@ export function SettingsPage() { +
+

Mail service

+
+
+ +
+ +
+ +
+ + +
+ + + +
+ + + +
+
+

Default container environment variables

diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md index 967723fb..8d6d5a76 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md +++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md @@ -103,6 +103,18 @@ On container or service deletion, cross-site A records are cleaned up automatica When creating a container service, users select an external domain and specify a subdomain (e.g., `app` for `app.example.com`). All external domains are available regardless of which site the container is on. See the [Web GUI guide](../../users/creating-containers/web-gui.md) for details. +## Mail + +Every domain gets an RSA-2048 DKIM key at creation. Mail stays off until an admin enables it on the domain's edit page: + +1. Publish the DNS records shown in the **Mail** section (SPF, DKIM, DMARC; MX to receive). Copy buttons provided. +2. **Check DNS** — verified against public resolvers (configurable in [Settings](../email.md#settings)). +3. **Enable mail** — refused (`dns_not_configured`) until SPF + DKIM + DMARC verify. A failing MX only blocks *receiving*. + +Gates: `enabled + DNS verified = can send` (accounts can be created and SMTP login works); `enabled + MX verified = can receive`. Re-verification is manual — re-run Check DNS after changing records. Disabling keeps keys and config; accounts stop authenticating immediately, IMAP stays readable. + +PTR problems and missing `postmaster@`/`abuse@` mailboxes are warnings, not blockers. See [Email Service](../email.md) for the mail host and settings. + ## Security - Issue Cloudflare User API Tokens with minimum scope (`Zone:DNS:Edit` for the target zone only) diff --git a/mie-opensource-landing/docs/admins/deploying-agents.md b/mie-opensource-landing/docs/admins/deploying-agents.md index 133c93f4..0a806bae 100644 --- a/mie-opensource-landing/docs/admins/deploying-agents.md +++ b/mie-opensource-landing/docs/admins/deploying-agents.md @@ -70,6 +70,7 @@ lxc.environment = API_KEY= | `SITE_ID` | Numeric site ID from the manager (visible in the URL when viewing the site) | | `MANAGER_URL` | Base URL of the manager container (e.g., `http://192.168.1.10:3000`) | | `API_KEY` | API key from an admin account. Used to authenticate check-ins. | +| `AGENT_SERVICES` | Optional. Comma-separated service groups to run: `nginx`, `dnsmasq`, `mail`. Default `nginx,dnsmasq`. Add `mail` to make this host the [mail host](email.md) (install the mail packages first). | ## Volume storage for persistent volumes diff --git a/mie-opensource-landing/docs/admins/email.md b/mie-opensource-landing/docs/admins/email.md new file mode 100644 index 00000000..bb5b926d --- /dev/null +++ b/mie-opensource-landing/docs/admins/email.md @@ -0,0 +1,53 @@ +# Email Service + +Authenticated SMTP submission + IMAP hosted by an agent (Postfix + Dovecot + OpenDKIM), with DKIM signing, per-domain enablement, and RFC 8058 one-click unsubscribe. User-facing usage is covered in [Email Accounts](../users/email-accounts.md); per-domain DNS setup in [External Domains](core-concepts/external-domains.md#mail). + +## The Mail Host + +Exactly one agent runs the mail stack at a time (mailboxes are local Maildir). The claim goes to the first agent that checks in with the `mail` service group enabled and all packages installed; other agents show **Standby** on the Agents page. Release the claim from the Agents page (**Release mail host**) — the next qualifying check-in takes it. + +The mail IP is the `externalIp` of the mail host's site. Dovecot and Postfix read accounts **live** from the manager's database through three read-only views (`mail_accounts_v`, `mail_senders_v`, `mail_suppressions_v`), so disabling an account or domain takes effect immediately (bounded by Dovecot's 1-minute auth cache). + +## Settings + +Settings → Mail service: + +| Setting | Purpose | +|---------|---------| +| Mail hostname | HELO name and MX target. Set to the forward-confirmed PTR of the mail IP — use **Suggest from PTR**. | +| Relayhost (+ credentials) | Optional smarthost. Requires **SPF include**, since mail then leaves from the relay's IPs | +| SPF include | `include:` added to the suggested SPF records | +| DNS check resolvers | Resolvers used by Check DNS (default `1.1.1.1`, `8.8.8.8`) | +| Mail DB host | Only when the mail host cannot reach the DB over the local socket | +| Self-managed site id | Mail IP source when no agent runs mail (option C below) | +| Default quota / message size limit | `1024` MB and `25` MB when empty | + +One-click unsubscribe links (`/u/`) use the mail host agent's `MANAGER_URL` as the base — set it to the manager's public URL. + +## Where Mail Runs + +**A. Manager (default).** The manager image ships the packages and sets `AGENT_SERVICES=nginx,dnsmasq,mail`. DB access uses Postgres peer auth (`pg_ident` maps the `postfix`/`dovecot`/`opensource-mail` OS users to the `mail_postfix`/`mail_dovecot` roles) — no passwords. + +**B. Another agent host.** + +1. Install the suggested packages: `apt install opensource-mail-helper postfix postfix-pgsql dovecot-core dovecot-imapd dovecot-lmtpd dovecot-pgsql opendkim` (use the `-mysql` variants on MySQL/MariaDB). +2. Install the per-domain certs under `/etc/ssl` (same layout as nginx). +3. Set the **Mail DB host** setting, give the `mail_postfix`/`mail_dovecot` roles passwords (`ALTER ROLE mail_postfix WITH PASSWORD '...'`), and put `MAIL_POSTFIX_DB_PASSWORD` / `MAIL_DOVECOT_DB_PASSWORD` in the agent's `/etc/environment`. +4. Make sure the agent checks in over HTTPS — the manager refuses to send the mail snapshot (DKIM keys, relay credentials) over plaintext (`insecure_transport`). +5. Remove `mail` from the manager's `AGENT_SERVICES`, add it on the new host (see [Deploying Agents](deploying-agents.md)). It takes the claim. +6. `rsync /var/vmail` from the old host. +7. Repoint the port forwards (25, 143, 465, 587, 993) and re-run Check DNS on each domain — the mail IP follows the host's site automatically. + +**C. Self-managed MTA (no agent runs `mail`).** + +1. Set **Self-managed site id** to the site whose `externalIp` sends mail. +2. Point Dovecot at `mail_accounts_v` and Postfix at `mail_senders_v` + `mail_recipients_v`, with Dovecot's `quota-status` guarding RCPT. The views are a documented, stable contract — see [Database Schema](../developers/database-schema.md#mail-views). +3. Export DKIM keys per domain (`GET /api/v1/external-domains/{id}/mail/dkim` — admin-only, audited in the manager log) and use the agent templates under `agent/templates/mail/` as a reference. Run the `opensource-mail-helper` milter yourself for suppressions + unsubscribe headers (it only needs the DB views and the unsubscribe keys in its `config.local.json`), or skip them. + +## DB Roles + +Migration `20261012000000` creates the read-only `mail_dovecot` and `mail_postfix` roles and grants SELECT on the views (the manager grants its app user `CREATEROLE` at first boot; elsewhere the migration logs the SQL for a DBA). Local mail hosts authenticate over the socket (Postgres peer via `pg_ident`); remote hosts need operator-set passwords (`ALTER ROLE … WITH PASSWORD`). + +## Backups + +Everything except mailbox content lives in the manager database (accounts, DKIM keys, suppressions, unsubscribe keys) — covered by your normal DB backup. Mailboxes are Maildirs under `/var/vmail/` on the mail host; back that directory up separately. Deleted accounts' Maildirs are garbage-collected 30 days after deletion. diff --git a/mie-opensource-landing/docs/admins/installation.md b/mie-opensource-landing/docs/admins/installation.md index 28287920..2f960c87 100644 --- a/mie-opensource-landing/docs/admins/installation.md +++ b/mie-opensource-landing/docs/admins/installation.md @@ -16,6 +16,10 @@ The following instructions make several assumptions for clarity. You may have to 4. The domains `example.org` and `*.example.org` have public DNS entries pointing to the firewall in front of this Proxmox cluster. 5. At least one Proxmox node is accessible at `https://example.org:8006` with a valid HTTPS certificate. +## Mail Ports and PTR + +To use the built-in [email service](email.md), additionally forward these TCP ports to the Manager container: `25` (SMTP), `587`/`465` (submission), `993`/`143` (IMAP). Deliverability requires a **PTR record** on the public mail IP that forward-confirms to the `Mail hostname` setting — arrange this with the IP's owner (ISP/hosting provider) before enabling mail on a domain. + ## Air-Gapped Installation The standard installation flow assumes that the Proxmox host can access GitHub Container Registry (GHCR). For an air-gapped deployment, container images must instead be downloaded on an Internet-connected machine and transferred into the isolated Proxmox network. diff --git a/mie-opensource-landing/docs/users/email-accounts.md b/mie-opensource-landing/docs/users/email-accounts.md new file mode 100644 index 00000000..a0aa3fd7 --- /dev/null +++ b/mie-opensource-landing/docs/users/email-accounts.md @@ -0,0 +1,41 @@ +# Email Accounts + +Send mail over authenticated SMTP and read replies over IMAP from any app in the environment — no external mail provider needed. Manage accounts under **Email** in the sidebar. + +## Creating an Account + +1. **Email → New email account**, pick a domain (only domains an admin has [enabled for mail](../admins/core-concepts/external-domains.md#mail) appear) and a local part, e.g. `my-app@example.com`. +2. Copy the generated password — it is shown **once**. Use **Rotate password** to get a new one at any time (the old one stops working immediately). + +Role addresses (`postmaster`, `abuse`, `noreply`, `admin`, …) are reserved for administrators. + +## Client Settings + +| | | +|---|---| +| Username | the full address (e.g. `my-app@example.com`) | +| SMTP | port `587` (STARTTLS) or `465` (implicit TLS) — authentication required, TLS always | +| IMAP | port `993` (TLS) or `143` | + +```bash +# Quick smoke test +curl --ssl-reqd --url smtp://example.com:587 \ + --user 'my-app@example.com:' \ + --mail-from my-app@example.com --mail-rcpt you@example.net \ + --upload-file message.eml +``` + +Mail is DKIM-signed automatically. You can only send from your own address — the envelope sender must match your login. + +## Unsubscribe Headers and Suppressions + +Outbound mail gets RFC 8058 `List-Unsubscribe` headers by default, so recipients can one-click unsubscribe (Gmail and others surface this as an "Unsubscribe" button). Per account: + +- **Unsubscribe headers** toggle — turn the headers off for purely transactional accounts. +- **Suppressions** — recipients who unsubscribed. Further mail from this account to them is rejected synchronously at submission (`5.7.1`), so your app sees the error instead of silently dropped mail. Only admins can remove a suppression. + +## Limits + +- Sending works only while the domain can send (admin-controlled, DNS-verified). If the domain is disabled, SMTP login is refused but IMAP stays readable. +- Mailboxes have a quota (shown in the list); over-quota delivery is rejected at RCPT. +- Disabled or deleted accounts stop authenticating immediately. A deleted account's mailbox is kept for 30 days. diff --git a/mie-opensource-landing/zensical.toml b/mie-opensource-landing/zensical.toml index 33100bf8..20056936 100644 --- a/mie-opensource-landing/zensical.toml +++ b/mie-opensource-landing/zensical.toml @@ -21,6 +21,7 @@ nav = [ { "API Keys" = "users/creating-containers/api-keys.md" }, ] }, { "Monitoring Containers" = "users/monitoring-container.md" }, + { "Email Accounts" = "users/email-accounts.md" }, { "Adding Authentication" = "users/consuming-auth.md" }, { "Launchpad" = [ { "What is Launchpad" = "users/proxmox-launchpad/what-is-proxmox-launchpad.md" }, @@ -43,6 +44,7 @@ nav = [ { "Volumes" = "admins/core-concepts/volumes.md" }, ] }, { "Settings" = "admins/settings.md" }, + { "Email Service" = "admins/email.md" }, { "OIDC Single Sign-On" = "admins/oidc.md" }, { "LDAP Authentication" = "admins/ldap-servers.md" }, { "Deploying Agents" = "admins/deploying-agents.md" },