chore: import upstream snapshot with attribution
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user