SmtpCredentials.tsx 13 KB

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