You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
memos/server/notification/email.go

321 lines
9.1 KiB
Go

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
}