memos/web/src/components/Settings/NotificationSection.tsx
Steven cd4f28ae10 feat(notification): add smtp email settings
- Add admin notification email settings UI and test-email RPC
- Dispatch privacy-first comment and mention emails through server notification layer
- Keep SMTP secrets write-only and require passwords when SMTP identity changes
2026-05-01 18:48:21 +08:00

308 lines
11 KiB
TypeScript

import { create } from "@bufbuild/protobuf";
import { isEqual } from "lodash-es";
import type { ChangeEvent } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { instanceServiceClient } from "@/connect";
import { useInstance } from "@/contexts/InstanceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { handleError } from "@/lib/error";
import {
InstanceSetting_Key,
InstanceSetting_NotificationSetting,
InstanceSetting_NotificationSetting_EmailSetting,
InstanceSetting_NotificationSetting_EmailSettingSchema,
InstanceSetting_NotificationSettingSchema,
InstanceSettingSchema,
TestInstanceEmailSettingRequestSchema,
} from "@/types/proto/api/v1/instance_service_pb";
import { useTranslate } from "@/utils/i18n";
import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
import useInstanceSettingUpdater, { buildInstanceSettingName } from "./useInstanceSettingUpdater";
const defaultEmailSetting = () =>
create(InstanceSetting_NotificationSetting_EmailSettingSchema, {
smtpPort: 587,
useTls: true,
});
const isEmailSettingConfigured = (email?: InstanceSetting_NotificationSetting_EmailSetting) => {
return Boolean(
email &&
(email.enabled ||
email.smtpHost.trim() ||
email.smtpPort > 0 ||
email.smtpUsername.trim() ||
email.fromEmail.trim() ||
email.fromName.trim() ||
email.replyTo.trim() ||
email.useTls ||
email.useSsl),
);
};
const normalizeNotificationSetting = (setting: InstanceSetting_NotificationSetting) =>
create(InstanceSetting_NotificationSettingSchema, {
...setting,
email: isEmailSettingConfigured(setting.email) ? setting.email : defaultEmailSetting(),
});
type Requirement = "required" | "optional" | "gmail" | "recommended";
const FieldLabel = ({ label, requirementLabel, requirement }: { label: string; requirementLabel: string; requirement: Requirement }) => (
<span className="inline-flex min-w-0 flex-wrap items-center gap-1.5">
<span>{label}</span>
<Badge
variant={requirement === "optional" ? "outline" : "secondary"}
className="rounded-md px-1.5 py-0 text-[10px] font-normal leading-4 text-muted-foreground"
>
{requirementLabel}
</Badge>
</span>
);
const NotificationSection = () => {
const t = useTranslate();
const saveInstanceSetting = useInstanceSettingUpdater();
const currentUser = useCurrentUser();
const { notificationSetting: originalSetting } = useInstance();
const normalizedOriginalSetting = useMemo(() => normalizeNotificationSetting(originalSetting), [originalSetting]);
const [notificationSetting, setNotificationSetting] = useState<InstanceSetting_NotificationSetting>(normalizedOriginalSetting);
const [isTestingEmail, setIsTestingEmail] = useState(false);
useEffect(() => {
setNotificationSetting(normalizedOriginalSetting);
}, [normalizedOriginalSetting]);
const emailSetting = notificationSetting.email ?? defaultEmailSetting();
const hasExistingEmailSetting = isEmailSettingConfigured(originalSetting.email);
const requirementLabel = (requirement: Requirement) => t(`setting.notification.requirement-${requirement}`);
const fieldLabel = (label: string, requirement: Requirement) => (
<FieldLabel label={label} requirement={requirement} requirementLabel={requirementLabel(requirement)} />
);
const updateEmailSetting = (partial: Partial<InstanceSetting_NotificationSetting_EmailSetting>) => {
const nextEmail = create(InstanceSetting_NotificationSetting_EmailSettingSchema, {
...emailSetting,
...partial,
});
setNotificationSetting(
create(InstanceSetting_NotificationSettingSchema, {
...notificationSetting,
email: nextEmail,
}),
);
};
const handlePortChanged = (event: ChangeEvent<HTMLInputElement>) => {
const port = parseInt(event.target.value);
updateEmailSetting({ smtpPort: Number.isNaN(port) ? 0 : port });
};
const handleUseTLSChanged = (checked: boolean) => {
updateEmailSetting({ useTls: checked, useSsl: checked ? false : emailSetting.useSsl });
};
const handleUseSSLChanged = (checked: boolean) => {
updateEmailSetting({ useSsl: checked, useTls: checked ? false : emailSetting.useTls });
};
const allowSave = useMemo(() => {
if (isEqual(normalizedOriginalSetting, notificationSetting)) {
return false;
}
const email = notificationSetting.email;
if (!email?.enabled) {
return true;
}
return Boolean(email.smtpHost.trim() && email.smtpPort > 0 && email.smtpPort <= 65535 && email.fromEmail.trim());
}, [notificationSetting, normalizedOriginalSetting]);
const canTestEmail = useMemo(() => {
return Boolean(
currentUser?.email &&
emailSetting.smtpHost.trim() &&
emailSetting.smtpPort > 0 &&
emailSetting.smtpPort <= 65535 &&
emailSetting.fromEmail.trim(),
);
}, [currentUser?.email, emailSetting.fromEmail, emailSetting.smtpHost, emailSetting.smtpPort]);
const saveNotificationSetting = async () => {
await saveInstanceSetting({
key: InstanceSetting_Key.NOTIFICATION,
setting: create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.NOTIFICATION),
value: {
case: "notificationSetting",
value: notificationSetting,
},
}),
errorContext: "Update notification settings",
});
};
const testEmailSetting = async () => {
if (!currentUser?.email) {
toast.error(t("setting.notification.test-email-missing-recipient"));
return;
}
setIsTestingEmail(true);
try {
await instanceServiceClient.testInstanceEmailSetting(
create(TestInstanceEmailSettingRequestSchema, {
email: emailSetting,
recipientEmail: currentUser.email,
}),
);
toast.success(t("setting.notification.test-email-success", { email: currentUser.email }));
} catch (error: unknown) {
await handleError(error, toast.error, { context: "Send test email" });
} finally {
setIsTestingEmail(false);
}
};
return (
<SettingSection title={t("setting.notification.label")}>
<SettingGroup title={t("setting.notification.email-title")} description={t("setting.notification.email-description")}>
<SettingRow label={t("setting.notification.email-enabled")} description={t("setting.notification.email-enabled-description")}>
<Switch checked={emailSetting.enabled} onCheckedChange={(enabled) => updateEmailSetting({ enabled })} />
</SettingRow>
<SettingRow
label={fieldLabel(t("setting.notification.smtp-host"), "required")}
description={t("setting.notification.smtp-host-description")}
>
<Input
className="w-full sm:w-80"
value={emailSetting.smtpHost}
placeholder="smtp.gmail.com"
aria-required={emailSetting.enabled}
onChange={(e) => updateEmailSetting({ smtpHost: e.target.value })}
/>
</SettingRow>
<SettingRow
label={fieldLabel(t("setting.notification.smtp-port"), "required")}
description={t("setting.notification.smtp-port-description")}
>
<Input
className="w-28 font-mono"
type="number"
min={1}
max={65535}
value={emailSetting.smtpPort}
placeholder="587"
aria-required={emailSetting.enabled}
onChange={handlePortChanged}
/>
</SettingRow>
<SettingRow
label={fieldLabel(t("setting.notification.smtp-username"), "gmail")}
description={t("setting.notification.smtp-username-description")}
>
<Input
className="w-full sm:w-80"
type="email"
value={emailSetting.smtpUsername}
placeholder="your.name@gmail.com"
autoComplete="username"
onChange={(e) => updateEmailSetting({ smtpUsername: e.target.value })}
/>
</SettingRow>
<SettingRow
label={fieldLabel(t("setting.notification.smtp-password"), "gmail")}
description={
hasExistingEmailSetting
? t("setting.notification.smtp-password-preserve-description")
: t("setting.notification.smtp-password-description")
}
>
<Input
className="w-full sm:w-80"
type="password"
value={emailSetting.smtpPassword}
placeholder={hasExistingEmailSetting ? t("setting.notification.smtp-password-placeholder-existing") : "abcd efgh ijkl mnop"}
autoComplete="new-password"
onChange={(e) => updateEmailSetting({ smtpPassword: e.target.value })}
/>
</SettingRow>
<SettingRow
label={fieldLabel(t("setting.notification.from-email"), "required")}
description={t("setting.notification.from-email-description")}
>
<Input
className="w-full sm:w-80"
type="email"
value={emailSetting.fromEmail}
placeholder="your.name@gmail.com"
aria-required={emailSetting.enabled}
onChange={(e) => updateEmailSetting({ fromEmail: e.target.value })}
/>
</SettingRow>
<SettingRow
label={fieldLabel(t("setting.notification.from-name"), "optional")}
description={t("setting.notification.from-name-description")}
>
<Input
className="w-full sm:w-80"
value={emailSetting.fromName}
placeholder="Memos"
onChange={(e) => updateEmailSetting({ fromName: e.target.value })}
/>
</SettingRow>
<SettingRow
label={fieldLabel(t("setting.notification.reply-to"), "optional")}
description={t("setting.notification.reply-to-description")}
>
<Input
className="w-full sm:w-80"
type="email"
value={emailSetting.replyTo}
placeholder="support@example.com"
onChange={(e) => updateEmailSetting({ replyTo: e.target.value })}
/>
</SettingRow>
<SettingRow
label={fieldLabel(t("setting.notification.use-tls"), "recommended")}
description={t("setting.notification.use-tls-description")}
>
<Switch checked={emailSetting.useTls} onCheckedChange={handleUseTLSChanged} />
</SettingRow>
<SettingRow
label={fieldLabel(t("setting.notification.use-ssl"), "optional")}
description={t("setting.notification.use-ssl-description")}
>
<Switch checked={emailSetting.useSsl} onCheckedChange={handleUseSSLChanged} />
</SettingRow>
</SettingGroup>
<div className="w-full flex flex-col justify-end gap-2 sm:flex-row">
<Button variant="outline" disabled={!canTestEmail || isTestingEmail} onClick={testEmailSetting}>
{isTestingEmail ? t("setting.notification.test-email-sending") : t("setting.notification.test-email")}
</Button>
<Button disabled={!allowSave} onClick={saveNotificationSetting}>
{t("common.save")}
</Button>
</div>
</SettingSection>
);
};
export default NotificationSection;