SmtpCredentials.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. import { CopyOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
  2. import { Button, Descriptions, Form, Input, InputNumber, Modal, Popconfirm, Space, Switch, Table, Typography } from 'antd';
  3. import type { ColumnsType } from 'antd/es/table';
  4. import { useState } from 'react';
  5. import { PageHeader } from '../components/common/PageHeader';
  6. import { SectionCard } from '../components/common/SectionCard';
  7. import { StatusPill } from '../components/common/StatusPill';
  8. import { useI18n } from '../frontend/i18n/react';
  9. import type { RuntimeConfig, SmtpCredential, SmtpRelay, SmtpRelayPayload } from '../frontend/types';
  10. interface SmtpCredentialsProps {
  11. config: RuntimeConfig | null;
  12. credential: SmtpCredential | null;
  13. credentials: SmtpCredential[];
  14. relays: SmtpRelay[];
  15. loading?: boolean;
  16. onCopy: (value: string) => void;
  17. onLoadCredential: (id: number) => Promise<SmtpCredential | null>;
  18. onSaveCredential: (values: { username: string; password?: string }, id?: number) => Promise<SmtpCredential | null>;
  19. onDeleteCredential: (credential: SmtpCredential) => Promise<void>;
  20. onLoadRelay: (id: number) => Promise<SmtpRelay | null>;
  21. onSaveRelay: (values: SmtpRelayPayload, id?: number) => Promise<SmtpRelay | null>;
  22. onDeleteRelay: (relay: SmtpRelay) => Promise<void>;
  23. }
  24. interface CredentialFormValues {
  25. username: string;
  26. password?: string;
  27. }
  28. export default function SmtpCredentials({
  29. config,
  30. credential,
  31. credentials,
  32. relays,
  33. loading,
  34. onCopy,
  35. onLoadCredential,
  36. onSaveCredential,
  37. onDeleteCredential,
  38. onLoadRelay,
  39. onSaveRelay,
  40. onDeleteRelay
  41. }: SmtpCredentialsProps) {
  42. const { t } = useI18n();
  43. const [credentialForm] = Form.useForm<CredentialFormValues>();
  44. const [relayForm] = Form.useForm<SmtpRelayPayload>();
  45. const [credentialOpen, setCredentialOpen] = useState(false);
  46. const [credentialLoading, setCredentialLoading] = useState(false);
  47. const [editingCredential, setEditingCredential] = useState<SmtpCredential | null>(null);
  48. const [relayOpen, setRelayOpen] = useState(false);
  49. const [relayLoading, setRelayLoading] = useState(false);
  50. const [editingRelay, setEditingRelay] = useState<SmtpRelay | null>(null);
  51. function generateCredentialPassword() {
  52. credentialForm.setFieldValue('password', randomPassword());
  53. }
  54. function generateRelayPassword() {
  55. relayForm.setFieldValue('password', randomPassword());
  56. }
  57. function openCreateCredential() {
  58. setEditingCredential(null);
  59. credentialForm.setFieldsValue({
  60. username: credentials.length === 0 ? credential?.username || config?.submission?.username || '' : '',
  61. password: ''
  62. });
  63. setCredentialOpen(true);
  64. }
  65. async function openEditCredential(item: SmtpCredential) {
  66. if (!item.id) return;
  67. setEditingCredential(item);
  68. setCredentialOpen(true);
  69. setCredentialLoading(true);
  70. try {
  71. const detail = await onLoadCredential(item.id);
  72. if (!detail) {
  73. setCredentialOpen(false);
  74. return;
  75. }
  76. credentialForm.setFieldsValue({
  77. username: detail.username,
  78. password: detail.password || ''
  79. });
  80. } finally {
  81. setCredentialLoading(false);
  82. }
  83. }
  84. async function saveCredential() {
  85. const values = await credentialForm.validateFields();
  86. setCredentialLoading(true);
  87. try {
  88. const saved = await onSaveCredential(values, editingCredential?.id);
  89. if (!saved) return;
  90. setCredentialOpen(false);
  91. credentialForm.resetFields();
  92. } finally {
  93. setCredentialLoading(false);
  94. }
  95. }
  96. function closeCredentialModal() {
  97. setCredentialOpen(false);
  98. setEditingCredential(null);
  99. credentialForm.resetFields();
  100. }
  101. function openCreateRelay() {
  102. setEditingRelay(null);
  103. relayForm.setFieldsValue({
  104. name: '',
  105. host: '',
  106. port: 587,
  107. secure: false,
  108. username: '',
  109. password: '',
  110. helo: '',
  111. isDefault: relays.length === 0
  112. });
  113. setRelayOpen(true);
  114. }
  115. async function openEditRelay(relay: SmtpRelay) {
  116. setEditingRelay(relay);
  117. setRelayOpen(true);
  118. setRelayLoading(true);
  119. try {
  120. const detail = await onLoadRelay(relay.id);
  121. if (!detail) {
  122. setRelayOpen(false);
  123. return;
  124. }
  125. relayForm.setFieldsValue({
  126. name: detail.name,
  127. host: detail.host,
  128. port: detail.port,
  129. secure: detail.secure,
  130. username: detail.username,
  131. password: detail.password || '',
  132. helo: detail.helo,
  133. isDefault: detail.isDefault
  134. });
  135. } finally {
  136. setRelayLoading(false);
  137. }
  138. }
  139. async function saveRelay() {
  140. const values = await relayForm.validateFields();
  141. setRelayLoading(true);
  142. try {
  143. const saved = await onSaveRelay(values, editingRelay?.id);
  144. if (!saved) return;
  145. setRelayOpen(false);
  146. relayForm.resetFields();
  147. } finally {
  148. setRelayLoading(false);
  149. }
  150. }
  151. const credentialColumns: ColumnsType<SmtpCredential> = [
  152. {
  153. title: t('smtp.username'),
  154. dataIndex: 'username',
  155. width: 240,
  156. render: (value: string) => copyable(value, onCopy)
  157. },
  158. {
  159. title: t('smtp.password'),
  160. dataIndex: 'password',
  161. width: 280,
  162. render: (value: string | undefined) => (
  163. value
  164. ? copyable(value, onCopy)
  165. : <Typography.Text type="secondary">{t('smtp.passwordUnavailable')}</Typography.Text>
  166. )
  167. },
  168. {
  169. title: t('common.status'),
  170. dataIndex: 'passwordSet',
  171. width: 120,
  172. render: (value: boolean) => (
  173. <StatusPill tone={value ? 'success' : 'neutral'}>
  174. {value ? t('smtp.passwordSet') : t('smtp.passwordEmpty')}
  175. </StatusPill>
  176. )
  177. },
  178. {
  179. title: t('tokens.createdAt'),
  180. dataIndex: 'createdAt',
  181. width: 190,
  182. render: formatDate
  183. },
  184. {
  185. title: t('domains.actions'),
  186. fixed: 'right',
  187. width: 150,
  188. render: (_, item) => (
  189. <Space>
  190. <Button icon={<EditOutlined />} onClick={() => openEditCredential(item)} disabled={!item.id} />
  191. <Popconfirm title={t('smtp.deleteConfirm')} onConfirm={() => onDeleteCredential(item)} disabled={!item.id}>
  192. <Button danger icon={<DeleteOutlined />} disabled={!item.id} />
  193. </Popconfirm>
  194. </Space>
  195. )
  196. }
  197. ];
  198. const relayColumns: ColumnsType<SmtpRelay> = [
  199. {
  200. title: t('smtpRelay.name'),
  201. dataIndex: 'name',
  202. width: 190,
  203. render: (value, relay) => (
  204. <Space wrap>
  205. <Typography.Text strong>{value}</Typography.Text>
  206. {relay.isDefault ? <StatusPill tone="success">{t('smtpRelay.default')}</StatusPill> : null}
  207. </Space>
  208. )
  209. },
  210. {
  211. title: t('smtpRelay.server'),
  212. width: 220,
  213. render: (_, relay) => (
  214. <Space direction="vertical" size={0}>
  215. <Typography.Text code>{relay.host}:{relay.port}</Typography.Text>
  216. <Typography.Text type="secondary">{relay.secure ? 'SSL/TLS' : 'STARTTLS / Plain'}</Typography.Text>
  217. </Space>
  218. )
  219. },
  220. { title: t('smtpRelay.username'), dataIndex: 'username', width: 180, render: (value) => value || '-' },
  221. {
  222. title: t('smtpRelay.password'),
  223. dataIndex: 'passwordSet',
  224. width: 120,
  225. render: (value: boolean) => (
  226. <StatusPill tone={value ? 'success' : 'neutral'}>
  227. {value ? t('smtpRelay.passwordSet') : t('smtpRelay.passwordEmpty')}
  228. </StatusPill>
  229. )
  230. },
  231. { title: 'HELO', dataIndex: 'helo', width: 180, render: (value) => value || '-' },
  232. {
  233. title: t('domains.actions'),
  234. fixed: 'right',
  235. width: 150,
  236. render: (_, relay) => (
  237. <Space>
  238. <Button icon={<EditOutlined />} onClick={() => openEditRelay(relay)} />
  239. <Popconfirm title={t('smtpRelay.deleteConfirm')} onConfirm={() => onDeleteRelay(relay)}>
  240. <Button danger icon={<DeleteOutlined />} />
  241. </Popconfirm>
  242. </Space>
  243. )
  244. }
  245. ];
  246. return (
  247. <Space direction="vertical" size={20} className="full-width">
  248. <PageHeader title={t('nav.smtp')} />
  249. <SectionCard title={t('smtp.connectionTitle')}>
  250. <Descriptions column={1}>
  251. <Descriptions.Item label="SMTP Host">{copyable(config?.submission?.host || '-', onCopy)}</Descriptions.Item>
  252. <Descriptions.Item label="SMTP Port">
  253. <Space wrap size={8}>
  254. {(config?.submission?.ports || []).map((item) => (
  255. <StatusPill key={item.port} tone="info">
  256. {item.port} · {item.protocol}
  257. </StatusPill>
  258. ))}
  259. </Space>
  260. </Descriptions.Item>
  261. <Descriptions.Item label="TLS / SSL">{config?.submission?.tls ? 'TLS' : 'STARTTLS'}</Descriptions.Item>
  262. <Descriptions.Item label={t('smtp.username')}>{copyable(credential?.username || config?.submission?.username || '-', onCopy)}</Descriptions.Item>
  263. <Descriptions.Item label={t('smtp.password')}>
  264. {credential?.password ? copyable(credential.password, onCopy) : <Typography.Text type="secondary">{t('smtp.resetToCopy')}</Typography.Text>}
  265. </Descriptions.Item>
  266. </Descriptions>
  267. </SectionCard>
  268. <SectionCard
  269. title={t('smtp.loginCredentialsTitle')}
  270. extra={
  271. <Button type="primary" icon={<PlusOutlined />} onClick={openCreateCredential}>
  272. {t('smtp.create')}
  273. </Button>
  274. }
  275. >
  276. <Table
  277. rowKey={(item) => item.id || item.username}
  278. columns={credentialColumns}
  279. dataSource={credentials}
  280. scroll={{ x: 980 }}
  281. pagination={credentials.length > 10 ? { pageSize: 10 } : false}
  282. />
  283. </SectionCard>
  284. <SectionCard
  285. title={t('smtpRelay.title')}
  286. extra={
  287. <Button type="primary" icon={<PlusOutlined />} onClick={openCreateRelay}>
  288. {t('smtpRelay.create')}
  289. </Button>
  290. }
  291. >
  292. <Table
  293. rowKey="id"
  294. columns={relayColumns}
  295. dataSource={relays}
  296. scroll={{ x: 1040 }}
  297. pagination={false}
  298. />
  299. </SectionCard>
  300. <Modal
  301. title={editingCredential ? t('smtp.editTitle') : t('smtp.createTitle')}
  302. open={credentialOpen}
  303. confirmLoading={loading || credentialLoading}
  304. onCancel={closeCredentialModal}
  305. onOk={saveCredential}
  306. width={560}
  307. destroyOnHidden
  308. >
  309. <Form form={credentialForm} layout="vertical">
  310. <Form.Item name="username" label={t('smtp.username')} rules={[{ required: true, message: t('smtp.usernameRequired') }]}>
  311. <Input autoComplete="off" />
  312. </Form.Item>
  313. <Form.Item
  314. name="password"
  315. label={t('smtp.password')}
  316. extra={editingCredential ? t('smtp.passwordExtra') : undefined}
  317. rules={[{ required: !editingCredential, message: t('smtp.passwordRequired') }]}
  318. >
  319. <Input autoComplete="new-password" />
  320. </Form.Item>
  321. <Form.Item>
  322. <Button icon={<ReloadOutlined />} onClick={generateCredentialPassword}>
  323. {t('smtp.regenerate')}
  324. </Button>
  325. </Form.Item>
  326. </Form>
  327. </Modal>
  328. <Modal
  329. title={editingRelay ? t('smtpRelay.editTitle') : t('smtpRelay.createTitle')}
  330. open={relayOpen}
  331. confirmLoading={loading || relayLoading}
  332. onCancel={() => setRelayOpen(false)}
  333. onOk={saveRelay}
  334. width={640}
  335. destroyOnHidden
  336. >
  337. <Form form={relayForm} layout="vertical">
  338. <Form.Item name="name" label={t('smtpRelay.name')} rules={[{ required: true, message: t('smtpRelay.nameRequired') }]}>
  339. <Input autoComplete="off" placeholder="Amazon SES" />
  340. </Form.Item>
  341. <Form.Item name="host" label="SMTP Host" rules={[{ required: true, message: t('smtpRelay.hostRequired') }]}>
  342. <Input autoComplete="off" placeholder="email-smtp.us-east-1.amazonaws.com" />
  343. </Form.Item>
  344. <Form.Item name="port" label="SMTP Port" rules={[{ required: true, message: t('smtpRelay.portRequired') }]}>
  345. <InputNumber min={1} max={65535} className="full-width" />
  346. </Form.Item>
  347. <Form.Item name="secure" label="SSL/TLS" valuePropName="checked">
  348. <Switch />
  349. </Form.Item>
  350. <Form.Item name="username" label={t('smtpRelay.username')}>
  351. <Input autoComplete="off" />
  352. </Form.Item>
  353. <Form.Item name="password" label={t('smtpRelay.password')} extra={t('smtpRelay.passwordExtra')}>
  354. <Input autoComplete="new-password" />
  355. </Form.Item>
  356. <Form.Item>
  357. <Button icon={<ReloadOutlined />} onClick={generateRelayPassword}>
  358. {t('smtpRelay.generatePassword')}
  359. </Button>
  360. </Form.Item>
  361. <Form.Item name="helo" label="HELO" extra={t('smtpRelay.heloExtra')}>
  362. <Input autoComplete="off" placeholder={config?.mailHostname || 'mail.example.com'} />
  363. </Form.Item>
  364. <Form.Item name="isDefault" label={t('smtpRelay.default')} valuePropName="checked">
  365. <Switch />
  366. </Form.Item>
  367. </Form>
  368. </Modal>
  369. </Space>
  370. );
  371. }
  372. function randomPassword() {
  373. const bytes = new Uint8Array(24);
  374. crypto.getRandomValues(bytes);
  375. return btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, '').slice(0, 28);
  376. }
  377. function copyable(value: string, onCopy: (value: string) => void) {
  378. return (
  379. <Space>
  380. <Typography.Text code className="inline-code-value">{value}</Typography.Text>
  381. <Button size="small" icon={<CopyOutlined />} onClick={() => onCopy(value)} />
  382. </Space>
  383. );
  384. }
  385. function formatDate(value?: string) {
  386. return value ? new Date(value).toLocaleString() : '-';
  387. }