AddDomainDrawer.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import { CheckOutlined, CloudOutlined, CodeOutlined, MailOutlined, SendOutlined } from '@ant-design/icons';
  2. import {
  3. Alert,
  4. Button,
  5. Checkbox,
  6. Collapse,
  7. Descriptions,
  8. Drawer,
  9. Form,
  10. Input,
  11. Radio,
  12. Select,
  13. Space,
  14. Steps,
  15. Typography
  16. } from 'antd';
  17. import { useEffect, useMemo, useState } from 'react';
  18. import { StatusPill } from '../common/StatusPill';
  19. import { useI18n } from '../../frontend/i18n/react';
  20. import type { AddDomainPayload, DnsCredential, RuntimeConfig, SmtpRelay } from '../../frontend/types';
  21. interface AddDomainDrawerProps {
  22. open: boolean;
  23. loading?: boolean;
  24. config: RuntimeConfig | null;
  25. dnsCredentials: DnsCredential[];
  26. smtpRelays: SmtpRelay[];
  27. onClose: () => void;
  28. onSubmit: (values: AddDomainPayload) => Promise<void>;
  29. }
  30. interface DomainWizardValues extends AddDomainPayload {
  31. purpose: 'sending' | 'sending-receiving';
  32. dnsMode: 'automatic' | 'manual';
  33. }
  34. export function AddDomainDrawer({
  35. open,
  36. loading,
  37. config,
  38. dnsCredentials,
  39. smtpRelays,
  40. onClose,
  41. onSubmit
  42. }: AddDomainDrawerProps) {
  43. const { locale } = useI18n();
  44. const copy = locale.startsWith('en') ? enCopy : zhCopy;
  45. const [form] = Form.useForm<DomainWizardValues>();
  46. const [current, setCurrent] = useState(0);
  47. const values = Form.useWatch([], form);
  48. const dnsMode = Form.useWatch('dnsMode', form);
  49. const steps = useMemo(() => [copy.domainAndPurpose, copy.dnsAndDelivery, copy.reviewAndCreate], [copy]);
  50. useEffect(() => {
  51. if (!open) return;
  52. form.resetFields();
  53. form.setFieldsValue({
  54. purpose: 'sending',
  55. dnsMode: dnsCredentials.length ? 'automatic' : 'manual',
  56. dnsCredentialId: dnsCredentials[0]?.id,
  57. smtpRelayId: undefined,
  58. senderHost: config?.mailHostname || '',
  59. sendingIp: config?.sendingIp || '',
  60. selector: defaultSelector(),
  61. dmarcPolicy: config?.dmarcPolicy || 'none',
  62. spfExtra: config?.defaultSpfMechanisms || '',
  63. immediateCheck: true
  64. });
  65. setCurrent(0);
  66. }, [config, dnsCredentials, form, open]);
  67. async function next() {
  68. try {
  69. await form.validateFields(stepFields(current));
  70. } catch {
  71. return;
  72. }
  73. if (current === 1 && dnsMode === 'automatic' && !form.getFieldValue('dnsCredentialId')) {
  74. form.setFields([{ name: 'dnsCredentialId', errors: [copy.selectDnsCredential] }]);
  75. return;
  76. }
  77. setCurrent((value) => Math.min(value + 1, steps.length - 1));
  78. }
  79. async function submit() {
  80. let result: DomainWizardValues;
  81. try {
  82. result = await form.validateFields();
  83. } catch {
  84. return;
  85. }
  86. const payload: AddDomainPayload = {
  87. domain: result.domain.trim().toLowerCase(),
  88. senderHost: result.senderHost?.trim(),
  89. sendingIp: result.sendingIp?.trim(),
  90. dnsCredentialId: result.dnsMode === 'automatic' ? result.dnsCredentialId : undefined,
  91. smtpRelayId: result.smtpRelayId || null,
  92. selector: result.selector?.trim(),
  93. dmarcPolicy: result.dmarcPolicy,
  94. spfExtra: result.spfExtra?.trim(),
  95. immediateCheck: result.immediateCheck
  96. };
  97. try {
  98. await onSubmit(payload);
  99. } catch {
  100. return;
  101. }
  102. form.resetFields();
  103. setCurrent(0);
  104. }
  105. return (
  106. <Drawer
  107. title={copy.title}
  108. width="min(640px, 100vw)"
  109. open={open}
  110. onClose={onClose}
  111. destroyOnHidden
  112. maskClosable={!loading}
  113. className="add-domain-drawer"
  114. styles={{ header: { padding: '16px 24px' }, body: { padding: '20px 24px 28px' }, footer: { padding: '12px 24px' } }}
  115. footer={
  116. <div className="drawer-footer">
  117. <Button style={{ minHeight: 44 }} disabled={loading} onClick={onClose}>{copy.cancel}</Button>
  118. <Space size={8}>
  119. <Button style={{ minHeight: 44 }} disabled={current === 0 || loading} onClick={() => setCurrent((value) => value - 1)}>{copy.previous}</Button>
  120. {current < steps.length - 1 ? (
  121. <Button type="primary" style={{ minHeight: 44 }} onClick={() => void next()}>{copy.next}</Button>
  122. ) : (
  123. <Button type="primary" style={{ minHeight: 44 }} icon={<CheckOutlined />} loading={loading} onClick={() => void submit()}>{copy.createAndVerify}</Button>
  124. )}
  125. </Space>
  126. </div>
  127. }
  128. >
  129. <Space direction="vertical" size={24} className="full-width">
  130. <Steps current={current} items={steps.map((title) => ({ title }))} responsive aria-label={copy.progress} />
  131. <Form form={form} layout="vertical" requiredMark="optional" preserve>
  132. <section hidden={current !== 0} aria-label={copy.domainAndPurpose}>
  133. <Form.Item
  134. name="domain"
  135. label={copy.domain}
  136. rules={[
  137. { required: true, message: copy.domainRequired },
  138. { pattern: /^(?!-)(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, message: copy.domainInvalid }
  139. ]}
  140. extra={copy.domainHint}
  141. >
  142. <Input placeholder="example.com" autoComplete="off" style={{ minHeight: 44 }} />
  143. </Form.Item>
  144. <Form.Item name="purpose" label={copy.purpose} rules={[{ required: true }]}>
  145. <Radio.Group style={{ width: '100%' }}>
  146. <Space direction="vertical" size={12} className="full-width">
  147. <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>
  148. <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>
  149. </Space>
  150. </Radio.Group>
  151. </Form.Item>
  152. {values?.purpose === 'sending-receiving' ? <Alert type="info" showIcon message={copy.receiveFollowUp} /> : null}
  153. </section>
  154. <section hidden={current !== 1} aria-label={copy.dnsAndDelivery}>
  155. <Form.Item name="dnsMode" label={copy.dnsConfiguration} rules={[{ required: true }]}>
  156. <Radio.Group optionType="button" buttonStyle="solid" style={{ minHeight: 44 }}>
  157. <Radio.Button value="automatic" disabled={!dnsCredentials.length}><CloudOutlined /> {copy.automatic}</Radio.Button>
  158. <Radio.Button value="manual"><CodeOutlined /> {copy.manual}</Radio.Button>
  159. </Radio.Group>
  160. </Form.Item>
  161. {dnsMode === 'automatic' ? (
  162. <Form.Item name="dnsCredentialId" label={copy.dnsCredential} rules={[{ required: true, message: copy.selectDnsCredential }]} extra={copy.autoDnsHint}>
  163. <Select
  164. placeholder={copy.selectDnsCredential}
  165. style={{ minHeight: 44 }}
  166. options={dnsCredentials.map((credential) => ({ value: credential.id, label: `${credential.name} · ${providerLabel(credential.provider)}` }))}
  167. />
  168. </Form.Item>
  169. ) : (
  170. <Alert type="info" showIcon message={copy.manualDnsTitle} description={copy.manualDnsHint} />
  171. )}
  172. <Form.Item name="smtpRelayId" label={copy.relay} extra={copy.relayHint} style={{ marginTop: 20 }}>
  173. <Select
  174. allowClear
  175. placeholder={copy.defaultDelivery}
  176. style={{ minHeight: 44 }}
  177. options={smtpRelays.map((relay) => ({ value: relay.id, label: relayLabel(relay, copy.defaultLabel) }))}
  178. />
  179. </Form.Item>
  180. <Collapse
  181. ghost
  182. items={[{
  183. key: 'advanced',
  184. label: copy.advanced,
  185. forceRender: true,
  186. children: (
  187. <>
  188. <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>
  189. <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>
  190. <Form.Item name="selector" label="DKIM selector" rules={[{ required: true, message: copy.selectorRequired }]}><Input placeholder="mh202607" autoComplete="off" style={{ minHeight: 44 }} /></Form.Item>
  191. <Form.Item name="dmarcPolicy" label="DMARC"><Select style={{ minHeight: 44 }} options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} /></Form.Item>
  192. <Form.Item name="spfExtra" label={copy.spfExtra}><Input.TextArea rows={3} placeholder="include:spf.example.com" /></Form.Item>
  193. </>
  194. )
  195. }]}
  196. />
  197. </section>
  198. <section hidden={current !== 2} aria-label={copy.reviewAndCreate}>
  199. <Alert type="info" showIcon message={copy.reviewHint} style={{ marginBottom: 20 }} />
  200. <Descriptions bordered size="small" column={1}>
  201. <Descriptions.Item label={copy.domain}>{values?.domain || '-'}</Descriptions.Item>
  202. <Descriptions.Item label={copy.purpose}>{values?.purpose === 'sending-receiving' ? copy.sendAndReceive : copy.sendingOnly}</Descriptions.Item>
  203. <Descriptions.Item label={copy.dnsConfiguration}>
  204. <StatusPill tone={values?.dnsMode === 'automatic' ? 'info' : 'neutral'}>{values?.dnsMode === 'automatic' ? copy.automatic : copy.manual}</StatusPill>
  205. </Descriptions.Item>
  206. <Descriptions.Item label={copy.dnsCredential}>{values?.dnsMode === 'automatic' ? dnsCredentials.find((item) => item.id === values?.dnsCredentialId)?.name || '-' : copy.notApplicable}</Descriptions.Item>
  207. <Descriptions.Item label={copy.relay}>{smtpRelays.find((item) => item.id === values?.smtpRelayId)?.name || copy.defaultDelivery}</Descriptions.Item>
  208. <Descriptions.Item label="DKIM selector">{values?.selector || '-'}</Descriptions.Item>
  209. <Descriptions.Item label="DMARC">{values?.dmarcPolicy || 'none'}</Descriptions.Item>
  210. </Descriptions>
  211. <Form.Item name="immediateCheck" valuePropName="checked" style={{ marginTop: 20 }}>
  212. <Checkbox>{copy.verifyImmediately}</Checkbox>
  213. </Form.Item>
  214. </section>
  215. </Form>
  216. </Space>
  217. </Drawer>
  218. );
  219. }
  220. function stepFields(step: number): Array<keyof DomainWizardValues> {
  221. if (step === 0) return ['domain', 'purpose'];
  222. if (step === 1) return ['dnsMode', 'dnsCredentialId', 'smtpRelayId', 'senderHost', 'sendingIp', 'selector', 'dmarcPolicy', 'spfExtra'];
  223. return [];
  224. }
  225. function defaultSelector() {
  226. const date = new Date();
  227. return `mh${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
  228. }
  229. function providerLabel(provider: string) {
  230. return ({ cloudflare: 'Cloudflare', aliyun: 'Aliyun DNS', dnspod: 'Tencent DNSPod' } as Record<string, string>)[provider] || provider;
  231. }
  232. function relayLabel(relay: SmtpRelay, defaultLabel: string) {
  233. return `${relay.name}${relay.isDefault ? ` · ${defaultLabel}` : ''} · ${relay.host}:${relay.port}`;
  234. }
  235. const zhCopy = {
  236. title: '添加发信域名', progress: '添加域名进度', domainAndPurpose: '域名与用途', dnsAndDelivery: 'DNS 与投递', reviewAndCreate: '检查并创建', cancel: '取消', previous: '上一步', next: '下一步', createAndVerify: '创建并验证',
  237. domain: '域名', domainRequired: '请输入域名', domainInvalid: '请输入有效的根域名', domainHint: '请输入用于发件地址的根域名,例如 example.com。', purpose: '用途', sendingOnly: '仅发送邮件', sendingOnlyHint: '配置 SPF、DKIM、DMARC 和发信主机。', sendAndReceive: '发送并接收邮件', sendAndReceiveHint: '创建后继续配置邮箱与收信路由。', receiveFollowUp: '域名创建后,可在“收信配置”页签中创建邮箱和路由。',
  238. 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 扩展机制',
  239. reviewHint: '创建操作不会隐藏任何现有能力;DNS 自动写入和验证结果可在域名详情中继续处理。', notApplicable: '不适用', verifyImmediately: '创建后立即检查 DNS 生效状态'
  240. };
  241. const enCopy: typeof zhCopy = {
  242. 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',
  243. 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.',
  244. 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',
  245. 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'
  246. };