mirror of https://github.com/usememos/memos
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 changespull/5925/head
parent
35bf761b8c
commit
cd4f28ae10
@ -0,0 +1,320 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/usememos/memos/internal/email"
|
||||
"github.com/usememos/memos/internal/profile"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
// EmailSender sends a prepared email message with the given SMTP configuration.
|
||||
type EmailSender func(*email.Config, *email.Message)
|
||||
|
||||
// EmailDispatcher dispatches notification emails for inbox events.
|
||||
type EmailDispatcher struct {
|
||||
profile *profile.Profile
|
||||
store *store.Store
|
||||
sender EmailSender
|
||||
}
|
||||
|
||||
// NewEmailDispatcher creates a notification email dispatcher.
|
||||
func NewEmailDispatcher(profile *profile.Profile, store *store.Store, sender EmailSender) *EmailDispatcher {
|
||||
if sender == nil {
|
||||
sender = email.SendAsync
|
||||
}
|
||||
return &EmailDispatcher{
|
||||
profile: profile,
|
||||
store: store,
|
||||
sender: sender,
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchInboxEmail sends the email notification for an inbox entry when configured.
|
||||
func (d *EmailDispatcher) DispatchInboxEmail(ctx context.Context, inbox *store.Inbox) error {
|
||||
if inbox == nil || inbox.Message == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
setting, err := d.store.GetInstanceNotificationSetting(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get notification setting")
|
||||
}
|
||||
emailSetting := setting.GetEmail()
|
||||
if emailSetting == nil || !emailSetting.Enabled {
|
||||
return nil
|
||||
}
|
||||
if d.baseURL() == "" {
|
||||
slog.Warn("Skipping inbox email notification because instance URL is required",
|
||||
slog.Int64("inbox_id", int64(inbox.ID)),
|
||||
slog.Int64("receiver_id", int64(inbox.ReceiverID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
receiver, err := d.store.GetUser(ctx, &store.FindUser{ID: &inbox.ReceiverID})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get notification receiver")
|
||||
}
|
||||
if receiver == nil || strings.TrimSpace(receiver.Email) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
sender, err := d.store.GetUser(ctx, &store.FindUser{ID: &inbox.SenderID})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get notification sender")
|
||||
}
|
||||
if sender == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
memosByID, err := d.listMemosByID(ctx, collectInboxMemoIDs([]*store.Inbox{inbox}))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get notification memos")
|
||||
}
|
||||
|
||||
message, err := d.buildInboxEmailMessage(inbox, receiver, sender, memosByID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if message == nil {
|
||||
return nil
|
||||
}
|
||||
message.ReplyTo = emailSetting.ReplyTo
|
||||
|
||||
config := EmailConfigFromInstanceSetting(emailSetting)
|
||||
if err := config.Validate(); err != nil {
|
||||
return errors.Wrap(err, "invalid notification email setting")
|
||||
}
|
||||
|
||||
d.sender(config, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmailConfigFromInstanceSetting converts persisted notification settings into SMTP config.
|
||||
func EmailConfigFromInstanceSetting(setting *storepb.InstanceNotificationSetting_EmailSetting) *email.Config {
|
||||
if setting == nil {
|
||||
return &email.Config{}
|
||||
}
|
||||
return &email.Config{
|
||||
SMTPHost: setting.SmtpHost,
|
||||
SMTPPort: int(setting.SmtpPort),
|
||||
SMTPUsername: setting.SmtpUsername,
|
||||
SMTPPassword: setting.SmtpPassword,
|
||||
FromEmail: setting.FromEmail,
|
||||
FromName: setting.FromName,
|
||||
UseTLS: setting.UseTls,
|
||||
UseSSL: setting.UseSsl,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestEmailMessage builds the plain-text test email for notification settings.
|
||||
func NewTestEmailMessage(recipientEmail, replyTo string) *email.Message {
|
||||
return &email.Message{
|
||||
To: []string{recipientEmail},
|
||||
Subject: "[Memos] Test email",
|
||||
Body: "This is a test email from your Memos notification settings.",
|
||||
ReplyTo: replyTo,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateEmailSetting validates notification email SMTP settings.
|
||||
func ValidateEmailSetting(setting *storepb.InstanceNotificationSetting_EmailSetting) error {
|
||||
return EmailConfigFromInstanceSetting(setting).Validate()
|
||||
}
|
||||
|
||||
// SendTestEmail sends a plain-text test email using notification email settings.
|
||||
func SendTestEmail(setting *storepb.InstanceNotificationSetting_EmailSetting, recipientEmail string) error {
|
||||
return email.Send(EmailConfigFromInstanceSetting(setting), NewTestEmailMessage(recipientEmail, setting.GetReplyTo()))
|
||||
}
|
||||
|
||||
func (d *EmailDispatcher) buildInboxEmailMessage(inbox *store.Inbox, receiver *store.User, sender *store.User, memosByID map[int32]*store.Memo) (*email.Message, error) {
|
||||
senderName := displayNameForEmail(sender)
|
||||
switch inbox.Message.Type {
|
||||
case storepb.InboxMessage_MEMO_COMMENT:
|
||||
return d.buildMemoCommentEmailMessage(inbox.Message, receiver, senderName, memosByID)
|
||||
case storepb.InboxMessage_MEMO_MENTION:
|
||||
return d.buildMemoMentionEmailMessage(inbox.Message, receiver, senderName, memosByID)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *EmailDispatcher) buildMemoCommentEmailMessage(message *storepb.InboxMessage, receiver *store.User, senderName string, memosByID map[int32]*store.Memo) (*email.Message, error) {
|
||||
payload := message.GetMemoComment()
|
||||
if payload == nil {
|
||||
return nil, nil
|
||||
}
|
||||
commentMemo := memosByID[payload.MemoId]
|
||||
relatedMemo := memosByID[payload.RelatedMemoId]
|
||||
if !canViewerAccessMemo(receiver, commentMemo) || !canViewerAccessMemo(receiver, relatedMemo) {
|
||||
return nil, nil
|
||||
}
|
||||
url := d.memoCommentURL(relatedMemo, commentMemo)
|
||||
if url == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body := []string{
|
||||
fmt.Sprintf("Hi %s,", displayNameForEmail(receiver)),
|
||||
"",
|
||||
fmt.Sprintf("%s commented on your memo.", senderName),
|
||||
"",
|
||||
"Open in Memos:",
|
||||
url,
|
||||
"",
|
||||
"You are receiving this because you own this memo.",
|
||||
}
|
||||
|
||||
return &email.Message{
|
||||
To: []string{receiver.Email},
|
||||
Subject: fmt.Sprintf("[Memos] %s commented on your memo", senderName),
|
||||
Body: strings.Join(body, "\n"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *EmailDispatcher) buildMemoMentionEmailMessage(message *storepb.InboxMessage, receiver *store.User, senderName string, memosByID map[int32]*store.Memo) (*email.Message, error) {
|
||||
payload := message.GetMemoMention()
|
||||
if payload == nil {
|
||||
return nil, nil
|
||||
}
|
||||
memo := memosByID[payload.MemoId]
|
||||
if !canViewerAccessMemo(receiver, memo) {
|
||||
return nil, nil
|
||||
}
|
||||
url := d.memoURL(memo)
|
||||
if url == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body := []string{
|
||||
fmt.Sprintf("Hi %s,", displayNameForEmail(receiver)),
|
||||
"",
|
||||
fmt.Sprintf("%s mentioned you in a memo.", senderName),
|
||||
"",
|
||||
"Open in Memos:",
|
||||
url,
|
||||
"",
|
||||
"You are receiving this because you were mentioned in this memo.",
|
||||
}
|
||||
|
||||
return &email.Message{
|
||||
To: []string{receiver.Email},
|
||||
Subject: fmt.Sprintf("[Memos] %s mentioned you in a memo", senderName),
|
||||
Body: strings.Join(body, "\n"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *EmailDispatcher) listMemosByID(ctx context.Context, memoIDs []int32) (map[int32]*store.Memo, error) {
|
||||
if len(memoIDs) == 0 {
|
||||
return map[int32]*store.Memo{}, nil
|
||||
}
|
||||
|
||||
uniqueMemoIDs := make([]int32, 0, len(memoIDs))
|
||||
seenMemoIDs := make(map[int32]struct{}, len(memoIDs))
|
||||
for _, memoID := range memoIDs {
|
||||
if memoID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, seen := seenMemoIDs[memoID]; seen {
|
||||
continue
|
||||
}
|
||||
seenMemoIDs[memoID] = struct{}{}
|
||||
uniqueMemoIDs = append(uniqueMemoIDs, memoID)
|
||||
}
|
||||
if len(uniqueMemoIDs) == 0 {
|
||||
return map[int32]*store.Memo{}, nil
|
||||
}
|
||||
|
||||
memos, err := d.store.ListMemos(ctx, &store.FindMemo{IDList: uniqueMemoIDs})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memosByID := make(map[int32]*store.Memo, len(memos))
|
||||
for _, memo := range memos {
|
||||
memosByID[memo.ID] = memo
|
||||
}
|
||||
return memosByID, nil
|
||||
}
|
||||
|
||||
func collectInboxMemoIDs(inboxes []*store.Inbox) []int32 {
|
||||
memoIDs := make([]int32, 0, len(inboxes)*2)
|
||||
for _, inbox := range inboxes {
|
||||
if inbox == nil || inbox.Message == nil {
|
||||
continue
|
||||
}
|
||||
switch inbox.Message.Type {
|
||||
case storepb.InboxMessage_MEMO_COMMENT:
|
||||
payload := inbox.Message.GetMemoComment()
|
||||
if payload != nil {
|
||||
memoIDs = append(memoIDs, payload.MemoId, payload.RelatedMemoId)
|
||||
}
|
||||
case storepb.InboxMessage_MEMO_MENTION:
|
||||
payload := inbox.Message.GetMemoMention()
|
||||
if payload != nil {
|
||||
memoIDs = append(memoIDs, payload.MemoId, payload.RelatedMemoId)
|
||||
}
|
||||
default:
|
||||
// Ignore notification types without memo references.
|
||||
}
|
||||
}
|
||||
return memoIDs
|
||||
}
|
||||
|
||||
func displayNameForEmail(user *store.User) string {
|
||||
if user == nil {
|
||||
return "there"
|
||||
}
|
||||
if strings.TrimSpace(user.Nickname) != "" {
|
||||
return user.Nickname
|
||||
}
|
||||
if strings.TrimSpace(user.Username) != "" {
|
||||
return user.Username
|
||||
}
|
||||
return "there"
|
||||
}
|
||||
|
||||
func (d *EmailDispatcher) baseURL() string {
|
||||
if d.profile == nil || strings.TrimSpace(d.profile.InstanceURL) == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(strings.TrimSpace(d.profile.InstanceURL), "/")
|
||||
}
|
||||
|
||||
func (d *EmailDispatcher) memoURL(memo *store.Memo) string {
|
||||
baseURL := d.baseURL()
|
||||
if memo == nil || memo.UID == "" || baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s/memos/%s", baseURL, memo.UID)
|
||||
}
|
||||
|
||||
func (d *EmailDispatcher) memoCommentURL(relatedMemo *store.Memo, commentMemo *store.Memo) string {
|
||||
baseURL := d.baseURL()
|
||||
if relatedMemo == nil || relatedMemo.UID == "" || commentMemo == nil || commentMemo.UID == "" || baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s/memos/%s#%s", baseURL, relatedMemo.UID, commentMemo.UID)
|
||||
}
|
||||
|
||||
func canViewerAccessMemo(viewer *store.User, memo *store.Memo) bool {
|
||||
if memo == nil {
|
||||
return false
|
||||
}
|
||||
if viewer != nil && viewer.Role == store.RoleAdmin {
|
||||
return true
|
||||
}
|
||||
if memo.Visibility == store.Private {
|
||||
return viewer != nil && viewer.ID == memo.CreatorID
|
||||
}
|
||||
if memo.Visibility == store.Protected {
|
||||
return viewer != nil
|
||||
}
|
||||
return true
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/usememos/memos/server/notification"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (s *APIV1Service) createInboxWithEmailNotification(ctx context.Context, inbox *store.Inbox) (*store.Inbox, error) {
|
||||
createdInbox, err := s.Store.CreateInbox(ctx, inbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.dispatchInboxEmailNotificationBestEffort(ctx, createdInbox)
|
||||
return createdInbox, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) dispatchInboxEmailNotificationBestEffort(ctx context.Context, inbox *store.Inbox) {
|
||||
dispatcher := notification.NewEmailDispatcher(s.Profile, s.Store, s.NotificationEmailSender)
|
||||
if err := dispatcher.DispatchInboxEmail(ctx, inbox); err != nil {
|
||||
slog.Warn("Failed to dispatch inbox email notification",
|
||||
slog.Any("err", err),
|
||||
slog.Int64("inbox_id", int64(inbox.ID)),
|
||||
slog.Int64("receiver_id", int64(inbox.ReceiverID)))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,308 @@
|
||||
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;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue