| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264 |
- import { CheckOutlined, CloudOutlined, CodeOutlined, MailOutlined, SendOutlined } from '@ant-design/icons';
- import {
- Alert,
- Button,
- Checkbox,
- Collapse,
- Descriptions,
- Drawer,
- Form,
- Input,
- Radio,
- Select,
- Space,
- Steps,
- Typography
- } from 'antd';
- import { useEffect, useMemo, useState } from 'react';
- import { StatusPill } from '../common/StatusPill';
- import { useI18n } from '../../frontend/i18n/react';
- import type { AddDomainPayload, DnsCredential, RuntimeConfig, SmtpRelay } from '../../frontend/types';
- interface AddDomainDrawerProps {
- open: boolean;
- loading?: boolean;
- config: RuntimeConfig | null;
- dnsCredentials: DnsCredential[];
- smtpRelays: SmtpRelay[];
- onClose: () => void;
- onSubmit: (values: AddDomainPayload) => Promise<void>;
- }
- interface DomainWizardValues extends AddDomainPayload {
- purpose: 'sending' | 'sending-receiving';
- dnsMode: 'automatic' | 'manual';
- }
- export function AddDomainDrawer({
- open,
- loading,
- config,
- dnsCredentials,
- smtpRelays,
- onClose,
- onSubmit
- }: AddDomainDrawerProps) {
- const { locale } = useI18n();
- const copy = locale.startsWith('en') ? enCopy : zhCopy;
- const [form] = Form.useForm<DomainWizardValues>();
- const [current, setCurrent] = useState(0);
- const values = Form.useWatch([], form);
- const dnsMode = Form.useWatch('dnsMode', form);
- const steps = useMemo(() => [copy.domainAndPurpose, copy.dnsAndDelivery, copy.reviewAndCreate], [copy]);
- useEffect(() => {
- if (!open) return;
- form.resetFields();
- form.setFieldsValue({
- purpose: 'sending',
- dnsMode: dnsCredentials.length ? 'automatic' : 'manual',
- dnsCredentialId: dnsCredentials[0]?.id,
- smtpRelayId: undefined,
- senderHost: config?.mailHostname || '',
- sendingIp: config?.sendingIp || '',
- selector: defaultSelector(),
- dmarcPolicy: config?.dmarcPolicy || 'none',
- spfExtra: config?.defaultSpfMechanisms || '',
- immediateCheck: true
- });
- setCurrent(0);
- }, [config, dnsCredentials, form, open]);
- async function next() {
- try {
- await form.validateFields(stepFields(current));
- } catch {
- return;
- }
- if (current === 1 && dnsMode === 'automatic' && !form.getFieldValue('dnsCredentialId')) {
- form.setFields([{ name: 'dnsCredentialId', errors: [copy.selectDnsCredential] }]);
- return;
- }
- setCurrent((value) => Math.min(value + 1, steps.length - 1));
- }
- async function submit() {
- let result: DomainWizardValues;
- try {
- result = await form.validateFields();
- } catch {
- return;
- }
- const payload: AddDomainPayload = {
- domain: result.domain.trim().toLowerCase(),
- senderHost: result.senderHost?.trim(),
- sendingIp: result.sendingIp?.trim(),
- dnsCredentialId: result.dnsMode === 'automatic' ? result.dnsCredentialId : undefined,
- smtpRelayId: result.smtpRelayId || null,
- selector: result.selector?.trim(),
- dmarcPolicy: result.dmarcPolicy,
- spfExtra: result.spfExtra?.trim(),
- immediateCheck: result.immediateCheck
- };
- try {
- await onSubmit(payload);
- } catch {
- return;
- }
- form.resetFields();
- setCurrent(0);
- }
- return (
- <Drawer
- title={copy.title}
- width="min(640px, 100vw)"
- open={open}
- onClose={onClose}
- destroyOnHidden
- maskClosable={!loading}
- className="add-domain-drawer"
- styles={{ header: { padding: '16px 24px' }, body: { padding: '20px 24px 28px' }, footer: { padding: '12px 24px' } }}
- footer={
- <div className="drawer-footer">
- <Button style={{ minHeight: 44 }} disabled={loading} onClick={onClose}>{copy.cancel}</Button>
- <Space size={8}>
- <Button style={{ minHeight: 44 }} disabled={current === 0 || loading} onClick={() => setCurrent((value) => value - 1)}>{copy.previous}</Button>
- {current < steps.length - 1 ? (
- <Button type="primary" style={{ minHeight: 44 }} onClick={() => void next()}>{copy.next}</Button>
- ) : (
- <Button type="primary" style={{ minHeight: 44 }} icon={<CheckOutlined />} loading={loading} onClick={() => void submit()}>{copy.createAndVerify}</Button>
- )}
- </Space>
- </div>
- }
- >
- <Space direction="vertical" size={24} className="full-width">
- <Steps current={current} items={steps.map((title) => ({ title }))} responsive aria-label={copy.progress} />
- <Form form={form} layout="vertical" requiredMark="optional" preserve>
- <section hidden={current !== 0} aria-label={copy.domainAndPurpose}>
- <Form.Item
- name="domain"
- label={copy.domain}
- rules={[
- { required: true, message: copy.domainRequired },
- { pattern: /^(?!-)(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, message: copy.domainInvalid }
- ]}
- extra={copy.domainHint}
- >
- <Input placeholder="example.com" autoComplete="off" style={{ minHeight: 44 }} />
- </Form.Item>
- <Form.Item name="purpose" label={copy.purpose} rules={[{ required: true }]}>
- <Radio.Group style={{ width: '100%' }}>
- <Space direction="vertical" size={12} className="full-width">
- <Radio value="sending"><Space><SendOutlined /><span><Typography.Text strong>{copy.sendingOnly}</Typography.Text><br /><Typography.Text type="secondary">{copy.sendingOnlyHint}</Typography.Text></span></Space></Radio>
- <Radio value="sending-receiving"><Space><MailOutlined /><span><Typography.Text strong>{copy.sendAndReceive}</Typography.Text><br /><Typography.Text type="secondary">{copy.sendAndReceiveHint}</Typography.Text></span></Space></Radio>
- </Space>
- </Radio.Group>
- </Form.Item>
- {values?.purpose === 'sending-receiving' ? <Alert type="info" showIcon message={copy.receiveFollowUp} /> : null}
- </section>
- <section hidden={current !== 1} aria-label={copy.dnsAndDelivery}>
- <Form.Item name="dnsMode" label={copy.dnsConfiguration} rules={[{ required: true }]}>
- <Radio.Group optionType="button" buttonStyle="solid" style={{ minHeight: 44 }}>
- <Radio.Button value="automatic" disabled={!dnsCredentials.length}><CloudOutlined /> {copy.automatic}</Radio.Button>
- <Radio.Button value="manual"><CodeOutlined /> {copy.manual}</Radio.Button>
- </Radio.Group>
- </Form.Item>
- {dnsMode === 'automatic' ? (
- <Form.Item name="dnsCredentialId" label={copy.dnsCredential} rules={[{ required: true, message: copy.selectDnsCredential }]} extra={copy.autoDnsHint}>
- <Select
- placeholder={copy.selectDnsCredential}
- style={{ minHeight: 44 }}
- options={dnsCredentials.map((credential) => ({ value: credential.id, label: `${credential.name} · ${providerLabel(credential.provider)}` }))}
- />
- </Form.Item>
- ) : (
- <Alert type="info" showIcon message={copy.manualDnsTitle} description={copy.manualDnsHint} />
- )}
- <Form.Item name="smtpRelayId" label={copy.relay} extra={copy.relayHint} style={{ marginTop: 20 }}>
- <Select
- allowClear
- placeholder={copy.defaultDelivery}
- style={{ minHeight: 44 }}
- options={smtpRelays.map((relay) => ({ value: relay.id, label: relayLabel(relay, copy.defaultLabel) }))}
- />
- </Form.Item>
- <Collapse
- ghost
- items={[{
- key: 'advanced',
- label: copy.advanced,
- forceRender: true,
- children: (
- <>
- <Form.Item name="senderHost" label={copy.senderHost} rules={[{ required: true, message: copy.senderHostRequired }]}><Input placeholder="mail.example.com" autoComplete="off" style={{ minHeight: 44 }} /></Form.Item>
- <Form.Item name="sendingIp" label={copy.sendingIp} rules={[{ required: true, message: copy.sendingIpRequired }]}><Input placeholder="203.0.113.10" autoComplete="off" style={{ minHeight: 44 }} /></Form.Item>
- <Form.Item name="selector" label="DKIM selector" rules={[{ required: true, message: copy.selectorRequired }]}><Input placeholder="mh202607" autoComplete="off" style={{ minHeight: 44 }} /></Form.Item>
- <Form.Item name="dmarcPolicy" label="DMARC"><Select style={{ minHeight: 44 }} options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} /></Form.Item>
- <Form.Item name="spfExtra" label={copy.spfExtra}><Input.TextArea rows={3} placeholder="include:spf.example.com" /></Form.Item>
- </>
- )
- }]}
- />
- </section>
- <section hidden={current !== 2} aria-label={copy.reviewAndCreate}>
- <Alert type="info" showIcon message={copy.reviewHint} style={{ marginBottom: 20 }} />
- <Descriptions bordered size="small" column={1}>
- <Descriptions.Item label={copy.domain}>{values?.domain || '-'}</Descriptions.Item>
- <Descriptions.Item label={copy.purpose}>{values?.purpose === 'sending-receiving' ? copy.sendAndReceive : copy.sendingOnly}</Descriptions.Item>
- <Descriptions.Item label={copy.dnsConfiguration}>
- <StatusPill tone={values?.dnsMode === 'automatic' ? 'info' : 'neutral'}>{values?.dnsMode === 'automatic' ? copy.automatic : copy.manual}</StatusPill>
- </Descriptions.Item>
- <Descriptions.Item label={copy.dnsCredential}>{values?.dnsMode === 'automatic' ? dnsCredentials.find((item) => item.id === values?.dnsCredentialId)?.name || '-' : copy.notApplicable}</Descriptions.Item>
- <Descriptions.Item label={copy.relay}>{smtpRelays.find((item) => item.id === values?.smtpRelayId)?.name || copy.defaultDelivery}</Descriptions.Item>
- <Descriptions.Item label="DKIM selector">{values?.selector || '-'}</Descriptions.Item>
- <Descriptions.Item label="DMARC">{values?.dmarcPolicy || 'none'}</Descriptions.Item>
- </Descriptions>
- <Form.Item name="immediateCheck" valuePropName="checked" style={{ marginTop: 20 }}>
- <Checkbox>{copy.verifyImmediately}</Checkbox>
- </Form.Item>
- </section>
- </Form>
- </Space>
- </Drawer>
- );
- }
- function stepFields(step: number): Array<keyof DomainWizardValues> {
- if (step === 0) return ['domain', 'purpose'];
- if (step === 1) return ['dnsMode', 'dnsCredentialId', 'smtpRelayId', 'senderHost', 'sendingIp', 'selector', 'dmarcPolicy', 'spfExtra'];
- return [];
- }
- function defaultSelector() {
- const date = new Date();
- return `mh${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
- }
- function providerLabel(provider: string) {
- return ({ cloudflare: 'Cloudflare', aliyun: 'Aliyun DNS', dnspod: 'Tencent DNSPod' } as Record<string, string>)[provider] || provider;
- }
- function relayLabel(relay: SmtpRelay, defaultLabel: string) {
- return `${relay.name}${relay.isDefault ? ` · ${defaultLabel}` : ''} · ${relay.host}:${relay.port}`;
- }
- const zhCopy = {
- title: '添加发信域名', progress: '添加域名进度', domainAndPurpose: '域名与用途', dnsAndDelivery: 'DNS 与投递', reviewAndCreate: '检查并创建', cancel: '取消', previous: '上一步', next: '下一步', createAndVerify: '创建并验证',
- domain: '域名', domainRequired: '请输入域名', domainInvalid: '请输入有效的根域名', domainHint: '请输入用于发件地址的根域名,例如 example.com。', purpose: '用途', sendingOnly: '仅发送邮件', sendingOnlyHint: '配置 SPF、DKIM、DMARC 和发信主机。', sendAndReceive: '发送并接收邮件', sendAndReceiveHint: '创建后继续配置邮箱与收信路由。', receiveFollowUp: '域名创建后,可在“收信配置”页签中创建邮箱和路由。',
- dnsConfiguration: 'DNS 配置方式', automatic: '自动配置', manual: '手动配置', dnsCredential: 'DNS 凭据', selectDnsCredential: '请选择 DNS 凭据', autoDnsHint: '创建后将使用该凭据写入所需记录;你仍可先检查摘要。', manualDnsTitle: '手动配置 DNS', manualDnsHint: '创建后会展示每条记录的主机名、类型和值,便于复制到当前 DNS 服务商。', relay: '发送中继', relayHint: '留空时按系统默认解析顺序投递。', defaultDelivery: '使用系统默认投递', defaultLabel: '默认', advanced: '高级选项', senderHost: '发信主机', senderHostRequired: '请输入发信主机', sendingIp: '发信 IP', sendingIpRequired: '请输入发信 IP', selectorRequired: '请输入 DKIM selector', spfExtra: 'SPF 扩展机制',
- reviewHint: '创建操作不会隐藏任何现有能力;DNS 自动写入和验证结果可在域名详情中继续处理。', notApplicable: '不适用', verifyImmediately: '创建后立即检查 DNS 生效状态'
- };
- const enCopy: typeof zhCopy = {
- title: 'Add sending domain', progress: 'Add domain progress', domainAndPurpose: 'Domain and purpose', dnsAndDelivery: 'DNS and delivery', reviewAndCreate: 'Review and create', cancel: 'Cancel', previous: 'Previous', next: 'Next', createAndVerify: 'Create and verify',
- domain: 'Domain', domainRequired: 'Enter a domain', domainInvalid: 'Enter a valid root domain', domainHint: 'Use the root domain for your From addresses, for example example.com.', purpose: 'Purpose', sendingOnly: 'Send email', sendingOnlyHint: 'Configure SPF, DKIM, DMARC, and the sending host.', sendAndReceive: 'Send and receive email', sendAndReceiveHint: 'Continue with mailbox and inbound routing after creation.', receiveFollowUp: 'After creation, configure mailboxes and routes from the Receiving tab.',
- dnsConfiguration: 'DNS configuration', automatic: 'Automatic', manual: 'Manual', dnsCredential: 'DNS credential', selectDnsCredential: 'Select a DNS credential', autoDnsHint: 'The credential can apply required records after creation; review them first if needed.', manualDnsTitle: 'Manual DNS configuration', manualDnsHint: 'The domain details will show each host, type, and value to copy into your DNS provider.', relay: 'Sending relay', relayHint: 'Leave empty to use the system delivery resolution order.', defaultDelivery: 'Use system delivery', defaultLabel: 'Default', advanced: 'Advanced options', senderHost: 'Sending host', senderHostRequired: 'Enter a sending host', sendingIp: 'Sending IP', sendingIpRequired: 'Enter a sending IP', selectorRequired: 'Enter a DKIM selector', spfExtra: 'Additional SPF mechanisms',
- reviewHint: 'Creation keeps all existing capabilities available. Continue DNS application and verification from domain details.', notApplicable: 'Not applicable', verifyImmediately: 'Check DNS status immediately after creation'
- };
|