mirror of https://github.com/usememos/memos
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.
321 lines
9.1 KiB
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
|
|
}
|