mirror of
https://github.com/usememos/memos.git
synced 2026-05-02 18:52:32 +00:00
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
This commit is contained in:
@@ -2,11 +2,15 @@ package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const smtpOperationTimeout = 15 * time.Second
|
||||
|
||||
// Client represents an SMTP email client.
|
||||
type Client struct {
|
||||
config *Config
|
||||
@@ -78,13 +82,32 @@ func (c *Client) Send(message *Message) error {
|
||||
func (c *Client) sendWithTLS(auth smtp.Auth, recipients []string, body string) error {
|
||||
serverAddr := c.config.GetServerAddress()
|
||||
|
||||
if c.config.UseTLS {
|
||||
// Use STARTTLS
|
||||
return smtp.SendMail(serverAddr, auth, c.config.FromEmail, recipients, []byte(body))
|
||||
dialer := &net.Dialer{Timeout: smtpOperationTimeout}
|
||||
conn, err := dialer.Dial("tcp", serverAddr)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to connect to SMTP server: %s", serverAddr)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := conn.SetDeadline(time.Now().Add(smtpOperationTimeout)); err != nil {
|
||||
return errors.Wrap(err, "failed to set SMTP connection deadline")
|
||||
}
|
||||
|
||||
// Send without encryption (not recommended)
|
||||
return smtp.SendMail(serverAddr, auth, c.config.FromEmail, recipients, []byte(body))
|
||||
client, err := smtp.NewClient(conn, c.config.SMTPHost)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create SMTP client")
|
||||
}
|
||||
defer client.Quit()
|
||||
|
||||
if c.config.UseTLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
return errors.New("SMTP server does not support STARTTLS")
|
||||
}
|
||||
if err := client.StartTLS(c.createTLSConfig()); err != nil {
|
||||
return errors.Wrap(err, "failed to start SMTP STARTTLS")
|
||||
}
|
||||
}
|
||||
|
||||
return c.sendWithClient(client, auth, recipients, body)
|
||||
}
|
||||
|
||||
// sendWithSSL sends email using SSL/TLS (port 465).
|
||||
@@ -93,11 +116,15 @@ func (c *Client) sendWithSSL(auth smtp.Auth, recipients []string, body string) e
|
||||
|
||||
// Create TLS connection
|
||||
tlsConfig := c.createTLSConfig()
|
||||
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
|
||||
dialer := &net.Dialer{Timeout: smtpOperationTimeout}
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", serverAddr, tlsConfig)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to connect to SMTP server with SSL: %s", serverAddr)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := conn.SetDeadline(time.Now().Add(smtpOperationTimeout)); err != nil {
|
||||
return errors.Wrap(err, "failed to set SMTP connection deadline")
|
||||
}
|
||||
|
||||
// Create SMTP client
|
||||
client, err := smtp.NewClient(conn, c.config.SMTPHost)
|
||||
@@ -106,7 +133,10 @@ func (c *Client) sendWithSSL(auth smtp.Auth, recipients []string, body string) e
|
||||
}
|
||||
defer client.Quit()
|
||||
|
||||
// Authenticate
|
||||
return c.sendWithClient(client, auth, recipients, body)
|
||||
}
|
||||
|
||||
func (c *Client) sendWithClient(client *smtp.Client, auth smtp.Auth, recipients []string, body string) error {
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return errors.Wrap(err, "SMTP authentication failed")
|
||||
|
||||
@@ -35,6 +35,12 @@ func (m *Message) Validate() error {
|
||||
// Format creates an RFC 5322 formatted email message.
|
||||
func (m *Message) Format(fromEmail, fromName string) string {
|
||||
var sb strings.Builder
|
||||
fromEmail = sanitizeEmailHeaderValue(fromEmail)
|
||||
fromName = sanitizeEmailHeaderValue(fromName)
|
||||
to := sanitizeEmailHeaderValues(m.To)
|
||||
cc := sanitizeEmailHeaderValues(m.Cc)
|
||||
replyTo := sanitizeEmailHeaderValue(m.ReplyTo)
|
||||
subject := sanitizeEmailHeaderValue(m.Subject)
|
||||
|
||||
// From header
|
||||
if fromName != "" {
|
||||
@@ -44,20 +50,20 @@ func (m *Message) Format(fromEmail, fromName string) string {
|
||||
}
|
||||
|
||||
// To header
|
||||
fmt.Fprintf(&sb, "To: %s\r\n", strings.Join(m.To, ", "))
|
||||
fmt.Fprintf(&sb, "To: %s\r\n", strings.Join(to, ", "))
|
||||
|
||||
// Cc header (optional)
|
||||
if len(m.Cc) > 0 {
|
||||
fmt.Fprintf(&sb, "Cc: %s\r\n", strings.Join(m.Cc, ", "))
|
||||
if len(cc) > 0 {
|
||||
fmt.Fprintf(&sb, "Cc: %s\r\n", strings.Join(cc, ", "))
|
||||
}
|
||||
|
||||
// Reply-To header (optional)
|
||||
if m.ReplyTo != "" {
|
||||
fmt.Fprintf(&sb, "Reply-To: %s\r\n", m.ReplyTo)
|
||||
if replyTo != "" {
|
||||
fmt.Fprintf(&sb, "Reply-To: %s\r\n", replyTo)
|
||||
}
|
||||
|
||||
// Subject header
|
||||
fmt.Fprintf(&sb, "Subject: %s\r\n", m.Subject)
|
||||
fmt.Fprintf(&sb, "Subject: %s\r\n", subject)
|
||||
|
||||
// Date header (RFC 5322 format)
|
||||
fmt.Fprintf(&sb, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
|
||||
@@ -81,6 +87,19 @@ func (m *Message) Format(fromEmail, fromName string) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func sanitizeEmailHeaderValue(value string) string {
|
||||
value = strings.NewReplacer("\r", " ", "\n", " ").Replace(value)
|
||||
return strings.Join(strings.Fields(value), " ")
|
||||
}
|
||||
|
||||
func sanitizeEmailHeaderValues(values []string) []string {
|
||||
sanitized := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
sanitized = append(sanitized, sanitizeEmailHeaderValue(value))
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// GetAllRecipients returns all recipients (To, Cc, Bcc) as a single slice.
|
||||
func (m *Message) GetAllRecipients() []string {
|
||||
var recipients []string
|
||||
|
||||
@@ -146,6 +146,29 @@ func TestMessageFormatMultipleRecipients(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageFormatSanitizesHeaderValues(t *testing.T) {
|
||||
msg := Message{
|
||||
To: []string{"user@example.com\r\nX-Injected-To: bad"},
|
||||
Cc: []string{"cc@example.com\r\nX-Injected-Cc: bad"},
|
||||
Subject: "Test\r\nX-Injected-Subject: bad",
|
||||
Body: "Test Body",
|
||||
ReplyTo: "reply@example.com\r\nX-Injected-Reply-To: bad",
|
||||
}
|
||||
|
||||
formatted := msg.Format("sender@example.com\r\nX-Injected-From: bad", "Sender\r\nX-Injected-Name: bad")
|
||||
headers := strings.SplitN(formatted, "\r\n\r\n", 2)[0]
|
||||
|
||||
if strings.Contains(headers, "\r\nX-Injected") {
|
||||
t.Fatalf("header value injection was not sanitized:\n%s", headers)
|
||||
}
|
||||
if !strings.Contains(headers, "Subject: Test X-Injected-Subject: bad") {
|
||||
t.Error("subject header was not normalized")
|
||||
}
|
||||
if !strings.Contains(headers, "From: Sender X-Injected-Name: bad <sender@example.com X-Injected-From: bad>") {
|
||||
t.Error("from header was not normalized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllRecipients(t *testing.T) {
|
||||
msg := Message{
|
||||
To: []string{"user1@example.com", "user2@example.com"},
|
||||
|
||||
@@ -7,6 +7,7 @@ import "google/api/annotations.proto";
|
||||
import "google/api/client.proto";
|
||||
import "google/api/field_behavior.proto";
|
||||
import "google/api/resource.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/field_mask.proto";
|
||||
import "google/type/color.proto";
|
||||
|
||||
@@ -32,6 +33,14 @@ service InstanceService {
|
||||
};
|
||||
option (google.api.method_signature) = "setting,update_mask";
|
||||
}
|
||||
|
||||
// Tests notification email delivery with the provided or stored SMTP settings.
|
||||
rpc TestInstanceEmailSetting(TestInstanceEmailSettingRequest) returns (google.protobuf.Empty) {
|
||||
option (google.api.http) = {
|
||||
post: "/api/v1/instance/settings/notification:testEmail"
|
||||
body: "*"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Instance profile message containing basic instance information.
|
||||
@@ -149,7 +158,7 @@ message InstanceSetting {
|
||||
// Reference: https://developers.cloudflare.com/r2/examples/aws/aws-sdk-go/
|
||||
message S3Config {
|
||||
string access_key_id = 1;
|
||||
string access_key_secret = 2;
|
||||
string access_key_secret = 2 [(google.api.field_behavior) = INPUT_ONLY];
|
||||
string endpoint = 3;
|
||||
string region = 4;
|
||||
string bucket = 5;
|
||||
@@ -199,7 +208,7 @@ message InstanceSetting {
|
||||
string smtp_host = 2;
|
||||
int32 smtp_port = 3;
|
||||
string smtp_username = 4;
|
||||
string smtp_password = 5;
|
||||
string smtp_password = 5 [(google.api.field_behavior) = INPUT_ONLY];
|
||||
string from_email = 6;
|
||||
string from_name = 7;
|
||||
string reply_to = 8;
|
||||
@@ -254,3 +263,12 @@ message UpdateInstanceSettingRequest {
|
||||
// The list of fields to update.
|
||||
google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
// Request message for TestInstanceEmailSetting method.
|
||||
message TestInstanceEmailSettingRequest {
|
||||
// Optional. SMTP email settings to test. If omitted, the stored notification email setting is used.
|
||||
InstanceSetting.NotificationSetting.EmailSetting email = 1 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Optional. Recipient email address. If omitted, the current user's email address is used.
|
||||
string recipient_email = 2 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
context "context"
|
||||
errors "errors"
|
||||
v1 "github.com/usememos/memos/proto/gen/api/v1"
|
||||
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||
http "net/http"
|
||||
strings "strings"
|
||||
)
|
||||
@@ -42,6 +43,9 @@ const (
|
||||
// InstanceServiceUpdateInstanceSettingProcedure is the fully-qualified name of the
|
||||
// InstanceService's UpdateInstanceSetting RPC.
|
||||
InstanceServiceUpdateInstanceSettingProcedure = "/memos.api.v1.InstanceService/UpdateInstanceSetting"
|
||||
// InstanceServiceTestInstanceEmailSettingProcedure is the fully-qualified name of the
|
||||
// InstanceService's TestInstanceEmailSetting RPC.
|
||||
InstanceServiceTestInstanceEmailSettingProcedure = "/memos.api.v1.InstanceService/TestInstanceEmailSetting"
|
||||
)
|
||||
|
||||
// InstanceServiceClient is a client for the memos.api.v1.InstanceService service.
|
||||
@@ -52,6 +56,8 @@ type InstanceServiceClient interface {
|
||||
GetInstanceSetting(context.Context, *connect.Request[v1.GetInstanceSettingRequest]) (*connect.Response[v1.InstanceSetting], error)
|
||||
// Updates an instance setting.
|
||||
UpdateInstanceSetting(context.Context, *connect.Request[v1.UpdateInstanceSettingRequest]) (*connect.Response[v1.InstanceSetting], error)
|
||||
// Tests notification email delivery with the provided or stored SMTP settings.
|
||||
TestInstanceEmailSetting(context.Context, *connect.Request[v1.TestInstanceEmailSettingRequest]) (*connect.Response[emptypb.Empty], error)
|
||||
}
|
||||
|
||||
// NewInstanceServiceClient constructs a client for the memos.api.v1.InstanceService service. By
|
||||
@@ -83,14 +89,21 @@ func NewInstanceServiceClient(httpClient connect.HTTPClient, baseURL string, opt
|
||||
connect.WithSchema(instanceServiceMethods.ByName("UpdateInstanceSetting")),
|
||||
connect.WithClientOptions(opts...),
|
||||
),
|
||||
testInstanceEmailSetting: connect.NewClient[v1.TestInstanceEmailSettingRequest, emptypb.Empty](
|
||||
httpClient,
|
||||
baseURL+InstanceServiceTestInstanceEmailSettingProcedure,
|
||||
connect.WithSchema(instanceServiceMethods.ByName("TestInstanceEmailSetting")),
|
||||
connect.WithClientOptions(opts...),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// instanceServiceClient implements InstanceServiceClient.
|
||||
type instanceServiceClient struct {
|
||||
getInstanceProfile *connect.Client[v1.GetInstanceProfileRequest, v1.InstanceProfile]
|
||||
getInstanceSetting *connect.Client[v1.GetInstanceSettingRequest, v1.InstanceSetting]
|
||||
updateInstanceSetting *connect.Client[v1.UpdateInstanceSettingRequest, v1.InstanceSetting]
|
||||
getInstanceProfile *connect.Client[v1.GetInstanceProfileRequest, v1.InstanceProfile]
|
||||
getInstanceSetting *connect.Client[v1.GetInstanceSettingRequest, v1.InstanceSetting]
|
||||
updateInstanceSetting *connect.Client[v1.UpdateInstanceSettingRequest, v1.InstanceSetting]
|
||||
testInstanceEmailSetting *connect.Client[v1.TestInstanceEmailSettingRequest, emptypb.Empty]
|
||||
}
|
||||
|
||||
// GetInstanceProfile calls memos.api.v1.InstanceService.GetInstanceProfile.
|
||||
@@ -108,6 +121,11 @@ func (c *instanceServiceClient) UpdateInstanceSetting(ctx context.Context, req *
|
||||
return c.updateInstanceSetting.CallUnary(ctx, req)
|
||||
}
|
||||
|
||||
// TestInstanceEmailSetting calls memos.api.v1.InstanceService.TestInstanceEmailSetting.
|
||||
func (c *instanceServiceClient) TestInstanceEmailSetting(ctx context.Context, req *connect.Request[v1.TestInstanceEmailSettingRequest]) (*connect.Response[emptypb.Empty], error) {
|
||||
return c.testInstanceEmailSetting.CallUnary(ctx, req)
|
||||
}
|
||||
|
||||
// InstanceServiceHandler is an implementation of the memos.api.v1.InstanceService service.
|
||||
type InstanceServiceHandler interface {
|
||||
// Gets the instance profile.
|
||||
@@ -116,6 +134,8 @@ type InstanceServiceHandler interface {
|
||||
GetInstanceSetting(context.Context, *connect.Request[v1.GetInstanceSettingRequest]) (*connect.Response[v1.InstanceSetting], error)
|
||||
// Updates an instance setting.
|
||||
UpdateInstanceSetting(context.Context, *connect.Request[v1.UpdateInstanceSettingRequest]) (*connect.Response[v1.InstanceSetting], error)
|
||||
// Tests notification email delivery with the provided or stored SMTP settings.
|
||||
TestInstanceEmailSetting(context.Context, *connect.Request[v1.TestInstanceEmailSettingRequest]) (*connect.Response[emptypb.Empty], error)
|
||||
}
|
||||
|
||||
// NewInstanceServiceHandler builds an HTTP handler from the service implementation. It returns the
|
||||
@@ -143,6 +163,12 @@ func NewInstanceServiceHandler(svc InstanceServiceHandler, opts ...connect.Handl
|
||||
connect.WithSchema(instanceServiceMethods.ByName("UpdateInstanceSetting")),
|
||||
connect.WithHandlerOptions(opts...),
|
||||
)
|
||||
instanceServiceTestInstanceEmailSettingHandler := connect.NewUnaryHandler(
|
||||
InstanceServiceTestInstanceEmailSettingProcedure,
|
||||
svc.TestInstanceEmailSetting,
|
||||
connect.WithSchema(instanceServiceMethods.ByName("TestInstanceEmailSetting")),
|
||||
connect.WithHandlerOptions(opts...),
|
||||
)
|
||||
return "/memos.api.v1.InstanceService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case InstanceServiceGetInstanceProfileProcedure:
|
||||
@@ -151,6 +177,8 @@ func NewInstanceServiceHandler(svc InstanceServiceHandler, opts ...connect.Handl
|
||||
instanceServiceGetInstanceSettingHandler.ServeHTTP(w, r)
|
||||
case InstanceServiceUpdateInstanceSettingProcedure:
|
||||
instanceServiceUpdateInstanceSettingHandler.ServeHTTP(w, r)
|
||||
case InstanceServiceTestInstanceEmailSettingProcedure:
|
||||
instanceServiceTestInstanceEmailSettingHandler.ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -171,3 +199,7 @@ func (UnimplementedInstanceServiceHandler) GetInstanceSetting(context.Context, *
|
||||
func (UnimplementedInstanceServiceHandler) UpdateInstanceSetting(context.Context, *connect.Request[v1.UpdateInstanceSettingRequest]) (*connect.Response[v1.InstanceSetting], error) {
|
||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.InstanceService.UpdateInstanceSetting is not implemented"))
|
||||
}
|
||||
|
||||
func (UnimplementedInstanceServiceHandler) TestInstanceEmailSetting(context.Context, *connect.Request[v1.TestInstanceEmailSettingRequest]) (*connect.Response[emptypb.Empty], error) {
|
||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.InstanceService.TestInstanceEmailSetting is not implemented"))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
color "google.golang.org/genproto/googleapis/type/color"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||
fieldmaskpb "google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
@@ -577,6 +578,61 @@ func (x *UpdateInstanceSettingRequest) GetUpdateMask() *fieldmaskpb.FieldMask {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Request message for TestInstanceEmailSetting method.
|
||||
type TestInstanceEmailSettingRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Optional. SMTP email settings to test. If omitted, the stored notification email setting is used.
|
||||
Email *InstanceSetting_NotificationSetting_EmailSetting `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"`
|
||||
// Optional. Recipient email address. If omitted, the current user's email address is used.
|
||||
RecipientEmail string `protobuf:"bytes,2,opt,name=recipient_email,json=recipientEmail,proto3" json:"recipient_email,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *TestInstanceEmailSettingRequest) Reset() {
|
||||
*x = TestInstanceEmailSettingRequest{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *TestInstanceEmailSettingRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*TestInstanceEmailSettingRequest) ProtoMessage() {}
|
||||
|
||||
func (x *TestInstanceEmailSettingRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use TestInstanceEmailSettingRequest.ProtoReflect.Descriptor instead.
|
||||
func (*TestInstanceEmailSettingRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_v1_instance_service_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *TestInstanceEmailSettingRequest) GetEmail() *InstanceSetting_NotificationSetting_EmailSetting {
|
||||
if x != nil {
|
||||
return x.Email
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TestInstanceEmailSettingRequest) GetRecipientEmail() string {
|
||||
if x != nil {
|
||||
return x.RecipientEmail
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// General instance settings configuration.
|
||||
type InstanceSetting_GeneralSetting struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -604,7 +660,7 @@ type InstanceSetting_GeneralSetting struct {
|
||||
|
||||
func (x *InstanceSetting_GeneralSetting) Reset() {
|
||||
*x = InstanceSetting_GeneralSetting{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[5]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -616,7 +672,7 @@ func (x *InstanceSetting_GeneralSetting) String() string {
|
||||
func (*InstanceSetting_GeneralSetting) ProtoMessage() {}
|
||||
|
||||
func (x *InstanceSetting_GeneralSetting) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[5]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -706,7 +762,7 @@ type InstanceSetting_StorageSetting struct {
|
||||
|
||||
func (x *InstanceSetting_StorageSetting) Reset() {
|
||||
*x = InstanceSetting_StorageSetting{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[6]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -718,7 +774,7 @@ func (x *InstanceSetting_StorageSetting) String() string {
|
||||
func (*InstanceSetting_StorageSetting) ProtoMessage() {}
|
||||
|
||||
func (x *InstanceSetting_StorageSetting) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[6]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -777,7 +833,7 @@ type InstanceSetting_MemoRelatedSetting struct {
|
||||
|
||||
func (x *InstanceSetting_MemoRelatedSetting) Reset() {
|
||||
*x = InstanceSetting_MemoRelatedSetting{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[7]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -789,7 +845,7 @@ func (x *InstanceSetting_MemoRelatedSetting) String() string {
|
||||
func (*InstanceSetting_MemoRelatedSetting) ProtoMessage() {}
|
||||
|
||||
func (x *InstanceSetting_MemoRelatedSetting) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[7]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -840,7 +896,7 @@ type InstanceSetting_TagMetadata struct {
|
||||
|
||||
func (x *InstanceSetting_TagMetadata) Reset() {
|
||||
*x = InstanceSetting_TagMetadata{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[8]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -852,7 +908,7 @@ func (x *InstanceSetting_TagMetadata) String() string {
|
||||
func (*InstanceSetting_TagMetadata) ProtoMessage() {}
|
||||
|
||||
func (x *InstanceSetting_TagMetadata) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[8]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -896,7 +952,7 @@ type InstanceSetting_TagsSetting struct {
|
||||
|
||||
func (x *InstanceSetting_TagsSetting) Reset() {
|
||||
*x = InstanceSetting_TagsSetting{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[9]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -908,7 +964,7 @@ func (x *InstanceSetting_TagsSetting) String() string {
|
||||
func (*InstanceSetting_TagsSetting) ProtoMessage() {}
|
||||
|
||||
func (x *InstanceSetting_TagsSetting) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[9]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[10]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -941,7 +997,7 @@ type InstanceSetting_NotificationSetting struct {
|
||||
|
||||
func (x *InstanceSetting_NotificationSetting) Reset() {
|
||||
*x = InstanceSetting_NotificationSetting{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[10]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -953,7 +1009,7 @@ func (x *InstanceSetting_NotificationSetting) String() string {
|
||||
func (*InstanceSetting_NotificationSetting) ProtoMessage() {}
|
||||
|
||||
func (x *InstanceSetting_NotificationSetting) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[10]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[11]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -987,7 +1043,7 @@ type InstanceSetting_AISetting struct {
|
||||
|
||||
func (x *InstanceSetting_AISetting) Reset() {
|
||||
*x = InstanceSetting_AISetting{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[11]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -999,7 +1055,7 @@ func (x *InstanceSetting_AISetting) String() string {
|
||||
func (*InstanceSetting_AISetting) ProtoMessage() {}
|
||||
|
||||
func (x *InstanceSetting_AISetting) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[11]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1041,7 +1097,7 @@ type InstanceSetting_AIProviderConfig struct {
|
||||
|
||||
func (x *InstanceSetting_AIProviderConfig) Reset() {
|
||||
*x = InstanceSetting_AIProviderConfig{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[12]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1053,7 +1109,7 @@ func (x *InstanceSetting_AIProviderConfig) String() string {
|
||||
func (*InstanceSetting_AIProviderConfig) ProtoMessage() {}
|
||||
|
||||
func (x *InstanceSetting_AIProviderConfig) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[12]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[13]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1130,7 +1186,7 @@ type InstanceSetting_GeneralSetting_CustomProfile struct {
|
||||
|
||||
func (x *InstanceSetting_GeneralSetting_CustomProfile) Reset() {
|
||||
*x = InstanceSetting_GeneralSetting_CustomProfile{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[13]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1142,7 +1198,7 @@ func (x *InstanceSetting_GeneralSetting_CustomProfile) String() string {
|
||||
func (*InstanceSetting_GeneralSetting_CustomProfile) ProtoMessage() {}
|
||||
|
||||
func (x *InstanceSetting_GeneralSetting_CustomProfile) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[13]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[14]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1195,7 +1251,7 @@ type InstanceSetting_StorageSetting_S3Config struct {
|
||||
|
||||
func (x *InstanceSetting_StorageSetting_S3Config) Reset() {
|
||||
*x = InstanceSetting_StorageSetting_S3Config{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[14]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1207,7 +1263,7 @@ func (x *InstanceSetting_StorageSetting_S3Config) String() string {
|
||||
func (*InstanceSetting_StorageSetting_S3Config) ProtoMessage() {}
|
||||
|
||||
func (x *InstanceSetting_StorageSetting_S3Config) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[14]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[15]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1284,7 +1340,7 @@ type InstanceSetting_NotificationSetting_EmailSetting struct {
|
||||
|
||||
func (x *InstanceSetting_NotificationSetting_EmailSetting) Reset() {
|
||||
*x = InstanceSetting_NotificationSetting_EmailSetting{}
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[16]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[17]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1296,7 +1352,7 @@ func (x *InstanceSetting_NotificationSetting_EmailSetting) String() string {
|
||||
func (*InstanceSetting_NotificationSetting_EmailSetting) ProtoMessage() {}
|
||||
|
||||
func (x *InstanceSetting_NotificationSetting_EmailSetting) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[16]
|
||||
mi := &file_api_v1_instance_service_proto_msgTypes[17]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1386,14 +1442,14 @@ var File_api_v1_instance_service_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_api_v1_instance_service_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x1dapi/v1/instance_service.proto\x12\fmemos.api.v1\x1a\x19api/v1/user_service.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a google/protobuf/field_mask.proto\x1a\x17google/type/color.proto\"\xa4\x01\n" +
|
||||
"\x1dapi/v1/instance_service.proto\x12\fmemos.api.v1\x1a\x19api/v1/user_service.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x17google/type/color.proto\"\xa4\x01\n" +
|
||||
"\x0fInstanceProfile\x12\x18\n" +
|
||||
"\aversion\x18\x02 \x01(\tR\aversion\x12\x12\n" +
|
||||
"\x04demo\x18\x03 \x01(\bR\x04demo\x12!\n" +
|
||||
"\finstance_url\x18\x06 \x01(\tR\vinstanceUrl\x12(\n" +
|
||||
"\x05admin\x18\a \x01(\v2\x12.memos.api.v1.UserR\x05admin\x12\x16\n" +
|
||||
"\x06commit\x18\b \x01(\tR\x06commit\"\x1b\n" +
|
||||
"\x19GetInstanceProfileRequest\"\xe6\x19\n" +
|
||||
"\x19GetInstanceProfileRequest\"\xf0\x19\n" +
|
||||
"\x0fInstanceSetting\x12\x17\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12W\n" +
|
||||
"\x0fgeneral_setting\x18\x02 \x01(\v2,.memos.api.v1.InstanceSetting.GeneralSettingH\x00R\x0egeneralSetting\x12W\n" +
|
||||
@@ -1415,15 +1471,15 @@ const file_api_v1_instance_service_proto_rawDesc = "" +
|
||||
"\rCustomProfile\x12\x14\n" +
|
||||
"\x05title\x18\x01 \x01(\tR\x05title\x12 \n" +
|
||||
"\vdescription\x18\x02 \x01(\tR\vdescription\x12\x19\n" +
|
||||
"\blogo_url\x18\x03 \x01(\tR\alogoUrl\x1a\xbc\x04\n" +
|
||||
"\blogo_url\x18\x03 \x01(\tR\alogoUrl\x1a\xc1\x04\n" +
|
||||
"\x0eStorageSetting\x12[\n" +
|
||||
"\fstorage_type\x18\x01 \x01(\x0e28.memos.api.v1.InstanceSetting.StorageSetting.StorageTypeR\vstorageType\x12+\n" +
|
||||
"\x11filepath_template\x18\x02 \x01(\tR\x10filepathTemplate\x12/\n" +
|
||||
"\x14upload_size_limit_mb\x18\x03 \x01(\x03R\x11uploadSizeLimitMb\x12R\n" +
|
||||
"\ts3_config\x18\x04 \x01(\v25.memos.api.v1.InstanceSetting.StorageSetting.S3ConfigR\bs3Config\x1a\xcc\x01\n" +
|
||||
"\ts3_config\x18\x04 \x01(\v25.memos.api.v1.InstanceSetting.StorageSetting.S3ConfigR\bs3Config\x1a\xd1\x01\n" +
|
||||
"\bS3Config\x12\"\n" +
|
||||
"\raccess_key_id\x18\x01 \x01(\tR\vaccessKeyId\x12*\n" +
|
||||
"\x11access_key_secret\x18\x02 \x01(\tR\x0faccessKeySecret\x12\x1a\n" +
|
||||
"\raccess_key_id\x18\x01 \x01(\tR\vaccessKeyId\x12/\n" +
|
||||
"\x11access_key_secret\x18\x02 \x01(\tB\x03\xe0A\x04R\x0faccessKeySecret\x12\x1a\n" +
|
||||
"\bendpoint\x18\x03 \x01(\tR\bendpoint\x12\x16\n" +
|
||||
"\x06region\x18\x04 \x01(\tR\x06region\x12\x16\n" +
|
||||
"\x06bucket\x18\x05 \x01(\tR\x06bucket\x12$\n" +
|
||||
@@ -1444,15 +1500,15 @@ const file_api_v1_instance_service_proto_rawDesc = "" +
|
||||
"\x04tags\x18\x01 \x03(\v23.memos.api.v1.InstanceSetting.TagsSetting.TagsEntryR\x04tags\x1ab\n" +
|
||||
"\tTagsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12?\n" +
|
||||
"\x05value\x18\x02 \x01(\v2).memos.api.v1.InstanceSetting.TagMetadataR\x05value:\x028\x01\x1a\xa3\x03\n" +
|
||||
"\x05value\x18\x02 \x01(\v2).memos.api.v1.InstanceSetting.TagMetadataR\x05value:\x028\x01\x1a\xa8\x03\n" +
|
||||
"\x13NotificationSetting\x12T\n" +
|
||||
"\x05email\x18\x01 \x01(\v2>.memos.api.v1.InstanceSetting.NotificationSetting.EmailSettingR\x05email\x1a\xb5\x02\n" +
|
||||
"\x05email\x18\x01 \x01(\v2>.memos.api.v1.InstanceSetting.NotificationSetting.EmailSettingR\x05email\x1a\xba\x02\n" +
|
||||
"\fEmailSetting\x12\x18\n" +
|
||||
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x1b\n" +
|
||||
"\tsmtp_host\x18\x02 \x01(\tR\bsmtpHost\x12\x1b\n" +
|
||||
"\tsmtp_port\x18\x03 \x01(\x05R\bsmtpPort\x12#\n" +
|
||||
"\rsmtp_username\x18\x04 \x01(\tR\fsmtpUsername\x12#\n" +
|
||||
"\rsmtp_password\x18\x05 \x01(\tR\fsmtpPassword\x12\x1d\n" +
|
||||
"\rsmtp_username\x18\x04 \x01(\tR\fsmtpUsername\x12(\n" +
|
||||
"\rsmtp_password\x18\x05 \x01(\tB\x03\xe0A\x04R\fsmtpPassword\x12\x1d\n" +
|
||||
"\n" +
|
||||
"from_email\x18\x06 \x01(\tR\tfromEmail\x12\x1b\n" +
|
||||
"\tfrom_name\x18\a \x01(\tR\bfromName\x12\x19\n" +
|
||||
@@ -1493,11 +1549,15 @@ const file_api_v1_instance_service_proto_rawDesc = "" +
|
||||
"\x1cUpdateInstanceSettingRequest\x12<\n" +
|
||||
"\asetting\x18\x01 \x01(\v2\x1d.memos.api.v1.InstanceSettingB\x03\xe0A\x02R\asetting\x12@\n" +
|
||||
"\vupdate_mask\x18\x02 \x01(\v2\x1a.google.protobuf.FieldMaskB\x03\xe0A\x01R\n" +
|
||||
"updateMask2\xdb\x03\n" +
|
||||
"updateMask\"\xaa\x01\n" +
|
||||
"\x1fTestInstanceEmailSettingRequest\x12Y\n" +
|
||||
"\x05email\x18\x01 \x01(\v2>.memos.api.v1.InstanceSetting.NotificationSetting.EmailSettingB\x03\xe0A\x01R\x05email\x12,\n" +
|
||||
"\x0frecipient_email\x18\x02 \x01(\tB\x03\xe0A\x01R\x0erecipientEmail2\xfc\x04\n" +
|
||||
"\x0fInstanceService\x12~\n" +
|
||||
"\x12GetInstanceProfile\x12'.memos.api.v1.GetInstanceProfileRequest\x1a\x1d.memos.api.v1.InstanceProfile\" \x82\xd3\xe4\x93\x02\x1a\x12\x18/api/v1/instance/profile\x12\x8f\x01\n" +
|
||||
"\x12GetInstanceSetting\x12'.memos.api.v1.GetInstanceSettingRequest\x1a\x1d.memos.api.v1.InstanceSetting\"1\xdaA\x04name\x82\xd3\xe4\x93\x02$\x12\"/api/v1/{name=instance/settings/*}\x12\xb5\x01\n" +
|
||||
"\x15UpdateInstanceSetting\x12*.memos.api.v1.UpdateInstanceSettingRequest\x1a\x1d.memos.api.v1.InstanceSetting\"Q\xdaA\x13setting,update_mask\x82\xd3\xe4\x93\x025:\asetting2*/api/v1/{setting.name=instance/settings/*}B\xac\x01\n" +
|
||||
"\x15UpdateInstanceSetting\x12*.memos.api.v1.UpdateInstanceSettingRequest\x1a\x1d.memos.api.v1.InstanceSetting\"Q\xdaA\x13setting,update_mask\x82\xd3\xe4\x93\x025:\asetting2*/api/v1/{setting.name=instance/settings/*}\x12\x9e\x01\n" +
|
||||
"\x18TestInstanceEmailSetting\x12-.memos.api.v1.TestInstanceEmailSettingRequest\x1a\x16.google.protobuf.Empty\";\x82\xd3\xe4\x93\x025:\x01*\"0/api/v1/instance/settings/notification:testEmailB\xac\x01\n" +
|
||||
"\x10com.memos.api.v1B\x14InstanceServiceProtoP\x01Z0github.com/usememos/memos/proto/gen/api/v1;apiv1\xa2\x02\x03MAX\xaa\x02\fMemos.Api.V1\xca\x02\fMemos\\Api\\V1\xe2\x02\x18Memos\\Api\\V1\\GPBMetadata\xea\x02\x0eMemos::Api::V1b\x06proto3"
|
||||
|
||||
var (
|
||||
@@ -1513,7 +1573,7 @@ func file_api_v1_instance_service_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_api_v1_instance_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
|
||||
var file_api_v1_instance_service_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
|
||||
var file_api_v1_instance_service_proto_msgTypes = make([]protoimpl.MessageInfo, 18)
|
||||
var file_api_v1_instance_service_proto_goTypes = []any{
|
||||
(InstanceSetting_Key)(0), // 0: memos.api.v1.InstanceSetting.Key
|
||||
(InstanceSetting_AIProviderType)(0), // 1: memos.api.v1.InstanceSetting.AIProviderType
|
||||
@@ -1523,52 +1583,57 @@ var file_api_v1_instance_service_proto_goTypes = []any{
|
||||
(*InstanceSetting)(nil), // 5: memos.api.v1.InstanceSetting
|
||||
(*GetInstanceSettingRequest)(nil), // 6: memos.api.v1.GetInstanceSettingRequest
|
||||
(*UpdateInstanceSettingRequest)(nil), // 7: memos.api.v1.UpdateInstanceSettingRequest
|
||||
(*InstanceSetting_GeneralSetting)(nil), // 8: memos.api.v1.InstanceSetting.GeneralSetting
|
||||
(*InstanceSetting_StorageSetting)(nil), // 9: memos.api.v1.InstanceSetting.StorageSetting
|
||||
(*InstanceSetting_MemoRelatedSetting)(nil), // 10: memos.api.v1.InstanceSetting.MemoRelatedSetting
|
||||
(*InstanceSetting_TagMetadata)(nil), // 11: memos.api.v1.InstanceSetting.TagMetadata
|
||||
(*InstanceSetting_TagsSetting)(nil), // 12: memos.api.v1.InstanceSetting.TagsSetting
|
||||
(*InstanceSetting_NotificationSetting)(nil), // 13: memos.api.v1.InstanceSetting.NotificationSetting
|
||||
(*InstanceSetting_AISetting)(nil), // 14: memos.api.v1.InstanceSetting.AISetting
|
||||
(*InstanceSetting_AIProviderConfig)(nil), // 15: memos.api.v1.InstanceSetting.AIProviderConfig
|
||||
(*InstanceSetting_GeneralSetting_CustomProfile)(nil), // 16: memos.api.v1.InstanceSetting.GeneralSetting.CustomProfile
|
||||
(*InstanceSetting_StorageSetting_S3Config)(nil), // 17: memos.api.v1.InstanceSetting.StorageSetting.S3Config
|
||||
nil, // 18: memos.api.v1.InstanceSetting.TagsSetting.TagsEntry
|
||||
(*InstanceSetting_NotificationSetting_EmailSetting)(nil), // 19: memos.api.v1.InstanceSetting.NotificationSetting.EmailSetting
|
||||
(*User)(nil), // 20: memos.api.v1.User
|
||||
(*fieldmaskpb.FieldMask)(nil), // 21: google.protobuf.FieldMask
|
||||
(*color.Color)(nil), // 22: google.type.Color
|
||||
(*TestInstanceEmailSettingRequest)(nil), // 8: memos.api.v1.TestInstanceEmailSettingRequest
|
||||
(*InstanceSetting_GeneralSetting)(nil), // 9: memos.api.v1.InstanceSetting.GeneralSetting
|
||||
(*InstanceSetting_StorageSetting)(nil), // 10: memos.api.v1.InstanceSetting.StorageSetting
|
||||
(*InstanceSetting_MemoRelatedSetting)(nil), // 11: memos.api.v1.InstanceSetting.MemoRelatedSetting
|
||||
(*InstanceSetting_TagMetadata)(nil), // 12: memos.api.v1.InstanceSetting.TagMetadata
|
||||
(*InstanceSetting_TagsSetting)(nil), // 13: memos.api.v1.InstanceSetting.TagsSetting
|
||||
(*InstanceSetting_NotificationSetting)(nil), // 14: memos.api.v1.InstanceSetting.NotificationSetting
|
||||
(*InstanceSetting_AISetting)(nil), // 15: memos.api.v1.InstanceSetting.AISetting
|
||||
(*InstanceSetting_AIProviderConfig)(nil), // 16: memos.api.v1.InstanceSetting.AIProviderConfig
|
||||
(*InstanceSetting_GeneralSetting_CustomProfile)(nil), // 17: memos.api.v1.InstanceSetting.GeneralSetting.CustomProfile
|
||||
(*InstanceSetting_StorageSetting_S3Config)(nil), // 18: memos.api.v1.InstanceSetting.StorageSetting.S3Config
|
||||
nil, // 19: memos.api.v1.InstanceSetting.TagsSetting.TagsEntry
|
||||
(*InstanceSetting_NotificationSetting_EmailSetting)(nil), // 20: memos.api.v1.InstanceSetting.NotificationSetting.EmailSetting
|
||||
(*User)(nil), // 21: memos.api.v1.User
|
||||
(*fieldmaskpb.FieldMask)(nil), // 22: google.protobuf.FieldMask
|
||||
(*color.Color)(nil), // 23: google.type.Color
|
||||
(*emptypb.Empty)(nil), // 24: google.protobuf.Empty
|
||||
}
|
||||
var file_api_v1_instance_service_proto_depIdxs = []int32{
|
||||
20, // 0: memos.api.v1.InstanceProfile.admin:type_name -> memos.api.v1.User
|
||||
8, // 1: memos.api.v1.InstanceSetting.general_setting:type_name -> memos.api.v1.InstanceSetting.GeneralSetting
|
||||
9, // 2: memos.api.v1.InstanceSetting.storage_setting:type_name -> memos.api.v1.InstanceSetting.StorageSetting
|
||||
10, // 3: memos.api.v1.InstanceSetting.memo_related_setting:type_name -> memos.api.v1.InstanceSetting.MemoRelatedSetting
|
||||
12, // 4: memos.api.v1.InstanceSetting.tags_setting:type_name -> memos.api.v1.InstanceSetting.TagsSetting
|
||||
13, // 5: memos.api.v1.InstanceSetting.notification_setting:type_name -> memos.api.v1.InstanceSetting.NotificationSetting
|
||||
14, // 6: memos.api.v1.InstanceSetting.ai_setting:type_name -> memos.api.v1.InstanceSetting.AISetting
|
||||
21, // 0: memos.api.v1.InstanceProfile.admin:type_name -> memos.api.v1.User
|
||||
9, // 1: memos.api.v1.InstanceSetting.general_setting:type_name -> memos.api.v1.InstanceSetting.GeneralSetting
|
||||
10, // 2: memos.api.v1.InstanceSetting.storage_setting:type_name -> memos.api.v1.InstanceSetting.StorageSetting
|
||||
11, // 3: memos.api.v1.InstanceSetting.memo_related_setting:type_name -> memos.api.v1.InstanceSetting.MemoRelatedSetting
|
||||
13, // 4: memos.api.v1.InstanceSetting.tags_setting:type_name -> memos.api.v1.InstanceSetting.TagsSetting
|
||||
14, // 5: memos.api.v1.InstanceSetting.notification_setting:type_name -> memos.api.v1.InstanceSetting.NotificationSetting
|
||||
15, // 6: memos.api.v1.InstanceSetting.ai_setting:type_name -> memos.api.v1.InstanceSetting.AISetting
|
||||
5, // 7: memos.api.v1.UpdateInstanceSettingRequest.setting:type_name -> memos.api.v1.InstanceSetting
|
||||
21, // 8: memos.api.v1.UpdateInstanceSettingRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
16, // 9: memos.api.v1.InstanceSetting.GeneralSetting.custom_profile:type_name -> memos.api.v1.InstanceSetting.GeneralSetting.CustomProfile
|
||||
2, // 10: memos.api.v1.InstanceSetting.StorageSetting.storage_type:type_name -> memos.api.v1.InstanceSetting.StorageSetting.StorageType
|
||||
17, // 11: memos.api.v1.InstanceSetting.StorageSetting.s3_config:type_name -> memos.api.v1.InstanceSetting.StorageSetting.S3Config
|
||||
22, // 12: memos.api.v1.InstanceSetting.TagMetadata.background_color:type_name -> google.type.Color
|
||||
18, // 13: memos.api.v1.InstanceSetting.TagsSetting.tags:type_name -> memos.api.v1.InstanceSetting.TagsSetting.TagsEntry
|
||||
19, // 14: memos.api.v1.InstanceSetting.NotificationSetting.email:type_name -> memos.api.v1.InstanceSetting.NotificationSetting.EmailSetting
|
||||
15, // 15: memos.api.v1.InstanceSetting.AISetting.providers:type_name -> memos.api.v1.InstanceSetting.AIProviderConfig
|
||||
1, // 16: memos.api.v1.InstanceSetting.AIProviderConfig.type:type_name -> memos.api.v1.InstanceSetting.AIProviderType
|
||||
11, // 17: memos.api.v1.InstanceSetting.TagsSetting.TagsEntry.value:type_name -> memos.api.v1.InstanceSetting.TagMetadata
|
||||
4, // 18: memos.api.v1.InstanceService.GetInstanceProfile:input_type -> memos.api.v1.GetInstanceProfileRequest
|
||||
6, // 19: memos.api.v1.InstanceService.GetInstanceSetting:input_type -> memos.api.v1.GetInstanceSettingRequest
|
||||
7, // 20: memos.api.v1.InstanceService.UpdateInstanceSetting:input_type -> memos.api.v1.UpdateInstanceSettingRequest
|
||||
3, // 21: memos.api.v1.InstanceService.GetInstanceProfile:output_type -> memos.api.v1.InstanceProfile
|
||||
5, // 22: memos.api.v1.InstanceService.GetInstanceSetting:output_type -> memos.api.v1.InstanceSetting
|
||||
5, // 23: memos.api.v1.InstanceService.UpdateInstanceSetting:output_type -> memos.api.v1.InstanceSetting
|
||||
21, // [21:24] is the sub-list for method output_type
|
||||
18, // [18:21] is the sub-list for method input_type
|
||||
18, // [18:18] is the sub-list for extension type_name
|
||||
18, // [18:18] is the sub-list for extension extendee
|
||||
0, // [0:18] is the sub-list for field type_name
|
||||
22, // 8: memos.api.v1.UpdateInstanceSettingRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
20, // 9: memos.api.v1.TestInstanceEmailSettingRequest.email:type_name -> memos.api.v1.InstanceSetting.NotificationSetting.EmailSetting
|
||||
17, // 10: memos.api.v1.InstanceSetting.GeneralSetting.custom_profile:type_name -> memos.api.v1.InstanceSetting.GeneralSetting.CustomProfile
|
||||
2, // 11: memos.api.v1.InstanceSetting.StorageSetting.storage_type:type_name -> memos.api.v1.InstanceSetting.StorageSetting.StorageType
|
||||
18, // 12: memos.api.v1.InstanceSetting.StorageSetting.s3_config:type_name -> memos.api.v1.InstanceSetting.StorageSetting.S3Config
|
||||
23, // 13: memos.api.v1.InstanceSetting.TagMetadata.background_color:type_name -> google.type.Color
|
||||
19, // 14: memos.api.v1.InstanceSetting.TagsSetting.tags:type_name -> memos.api.v1.InstanceSetting.TagsSetting.TagsEntry
|
||||
20, // 15: memos.api.v1.InstanceSetting.NotificationSetting.email:type_name -> memos.api.v1.InstanceSetting.NotificationSetting.EmailSetting
|
||||
16, // 16: memos.api.v1.InstanceSetting.AISetting.providers:type_name -> memos.api.v1.InstanceSetting.AIProviderConfig
|
||||
1, // 17: memos.api.v1.InstanceSetting.AIProviderConfig.type:type_name -> memos.api.v1.InstanceSetting.AIProviderType
|
||||
12, // 18: memos.api.v1.InstanceSetting.TagsSetting.TagsEntry.value:type_name -> memos.api.v1.InstanceSetting.TagMetadata
|
||||
4, // 19: memos.api.v1.InstanceService.GetInstanceProfile:input_type -> memos.api.v1.GetInstanceProfileRequest
|
||||
6, // 20: memos.api.v1.InstanceService.GetInstanceSetting:input_type -> memos.api.v1.GetInstanceSettingRequest
|
||||
7, // 21: memos.api.v1.InstanceService.UpdateInstanceSetting:input_type -> memos.api.v1.UpdateInstanceSettingRequest
|
||||
8, // 22: memos.api.v1.InstanceService.TestInstanceEmailSetting:input_type -> memos.api.v1.TestInstanceEmailSettingRequest
|
||||
3, // 23: memos.api.v1.InstanceService.GetInstanceProfile:output_type -> memos.api.v1.InstanceProfile
|
||||
5, // 24: memos.api.v1.InstanceService.GetInstanceSetting:output_type -> memos.api.v1.InstanceSetting
|
||||
5, // 25: memos.api.v1.InstanceService.UpdateInstanceSetting:output_type -> memos.api.v1.InstanceSetting
|
||||
24, // 26: memos.api.v1.InstanceService.TestInstanceEmailSetting:output_type -> google.protobuf.Empty
|
||||
23, // [23:27] is the sub-list for method output_type
|
||||
19, // [19:23] is the sub-list for method input_type
|
||||
19, // [19:19] is the sub-list for extension type_name
|
||||
19, // [19:19] is the sub-list for extension extendee
|
||||
0, // [0:19] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_api_v1_instance_service_proto_init() }
|
||||
@@ -1591,7 +1656,7 @@ func file_api_v1_instance_service_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_instance_service_proto_rawDesc), len(file_api_v1_instance_service_proto_rawDesc)),
|
||||
NumEnums: 3,
|
||||
NumMessages: 17,
|
||||
NumMessages: 18,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -176,6 +176,33 @@ func local_request_InstanceService_UpdateInstanceSetting_0(ctx context.Context,
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_InstanceService_TestInstanceEmailSetting_0(ctx context.Context, marshaler runtime.Marshaler, client InstanceServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq TestInstanceEmailSettingRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
msg, err := client.TestInstanceEmailSetting(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_InstanceService_TestInstanceEmailSetting_0(ctx context.Context, marshaler runtime.Marshaler, server InstanceServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq TestInstanceEmailSettingRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.TestInstanceEmailSetting(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
// RegisterInstanceServiceHandlerServer registers the http handlers for service InstanceService to "mux".
|
||||
// UnaryRPC :call InstanceServiceServer directly.
|
||||
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
|
||||
@@ -242,6 +269,26 @@ func RegisterInstanceServiceHandlerServer(ctx context.Context, mux *runtime.Serv
|
||||
}
|
||||
forward_InstanceService_UpdateInstanceSetting_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_InstanceService_TestInstanceEmailSetting_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.InstanceService/TestInstanceEmailSetting", runtime.WithHTTPPathPattern("/api/v1/instance/settings/notification:testEmail"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_InstanceService_TestInstanceEmailSetting_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_InstanceService_TestInstanceEmailSetting_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -333,17 +380,36 @@ func RegisterInstanceServiceHandlerClient(ctx context.Context, mux *runtime.Serv
|
||||
}
|
||||
forward_InstanceService_UpdateInstanceSetting_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_InstanceService_TestInstanceEmailSetting_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.InstanceService/TestInstanceEmailSetting", runtime.WithHTTPPathPattern("/api/v1/instance/settings/notification:testEmail"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_InstanceService_TestInstanceEmailSetting_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_InstanceService_TestInstanceEmailSetting_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
pattern_InstanceService_GetInstanceProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "instance", "profile"}, ""))
|
||||
pattern_InstanceService_GetInstanceSetting_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 3, 5, 4}, []string{"api", "v1", "instance", "settings", "name"}, ""))
|
||||
pattern_InstanceService_UpdateInstanceSetting_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 3, 5, 4}, []string{"api", "v1", "instance", "settings", "setting.name"}, ""))
|
||||
pattern_InstanceService_GetInstanceProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "instance", "profile"}, ""))
|
||||
pattern_InstanceService_GetInstanceSetting_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 3, 5, 4}, []string{"api", "v1", "instance", "settings", "name"}, ""))
|
||||
pattern_InstanceService_UpdateInstanceSetting_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 3, 5, 4}, []string{"api", "v1", "instance", "settings", "setting.name"}, ""))
|
||||
pattern_InstanceService_TestInstanceEmailSetting_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"api", "v1", "instance", "settings", "notification"}, "testEmail"))
|
||||
)
|
||||
|
||||
var (
|
||||
forward_InstanceService_GetInstanceProfile_0 = runtime.ForwardResponseMessage
|
||||
forward_InstanceService_GetInstanceSetting_0 = runtime.ForwardResponseMessage
|
||||
forward_InstanceService_UpdateInstanceSetting_0 = runtime.ForwardResponseMessage
|
||||
forward_InstanceService_GetInstanceProfile_0 = runtime.ForwardResponseMessage
|
||||
forward_InstanceService_GetInstanceSetting_0 = runtime.ForwardResponseMessage
|
||||
forward_InstanceService_UpdateInstanceSetting_0 = runtime.ForwardResponseMessage
|
||||
forward_InstanceService_TestInstanceEmailSetting_0 = runtime.ForwardResponseMessage
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
@@ -19,9 +20,10 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
InstanceService_GetInstanceProfile_FullMethodName = "/memos.api.v1.InstanceService/GetInstanceProfile"
|
||||
InstanceService_GetInstanceSetting_FullMethodName = "/memos.api.v1.InstanceService/GetInstanceSetting"
|
||||
InstanceService_UpdateInstanceSetting_FullMethodName = "/memos.api.v1.InstanceService/UpdateInstanceSetting"
|
||||
InstanceService_GetInstanceProfile_FullMethodName = "/memos.api.v1.InstanceService/GetInstanceProfile"
|
||||
InstanceService_GetInstanceSetting_FullMethodName = "/memos.api.v1.InstanceService/GetInstanceSetting"
|
||||
InstanceService_UpdateInstanceSetting_FullMethodName = "/memos.api.v1.InstanceService/UpdateInstanceSetting"
|
||||
InstanceService_TestInstanceEmailSetting_FullMethodName = "/memos.api.v1.InstanceService/TestInstanceEmailSetting"
|
||||
)
|
||||
|
||||
// InstanceServiceClient is the client API for InstanceService service.
|
||||
@@ -34,6 +36,8 @@ type InstanceServiceClient interface {
|
||||
GetInstanceSetting(ctx context.Context, in *GetInstanceSettingRequest, opts ...grpc.CallOption) (*InstanceSetting, error)
|
||||
// Updates an instance setting.
|
||||
UpdateInstanceSetting(ctx context.Context, in *UpdateInstanceSettingRequest, opts ...grpc.CallOption) (*InstanceSetting, error)
|
||||
// Tests notification email delivery with the provided or stored SMTP settings.
|
||||
TestInstanceEmailSetting(ctx context.Context, in *TestInstanceEmailSettingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
}
|
||||
|
||||
type instanceServiceClient struct {
|
||||
@@ -74,6 +78,16 @@ func (c *instanceServiceClient) UpdateInstanceSetting(ctx context.Context, in *U
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *instanceServiceClient) TestInstanceEmailSetting(ctx context.Context, in *TestInstanceEmailSettingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(emptypb.Empty)
|
||||
err := c.cc.Invoke(ctx, InstanceService_TestInstanceEmailSetting_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InstanceServiceServer is the server API for InstanceService service.
|
||||
// All implementations must embed UnimplementedInstanceServiceServer
|
||||
// for forward compatibility.
|
||||
@@ -84,6 +98,8 @@ type InstanceServiceServer interface {
|
||||
GetInstanceSetting(context.Context, *GetInstanceSettingRequest) (*InstanceSetting, error)
|
||||
// Updates an instance setting.
|
||||
UpdateInstanceSetting(context.Context, *UpdateInstanceSettingRequest) (*InstanceSetting, error)
|
||||
// Tests notification email delivery with the provided or stored SMTP settings.
|
||||
TestInstanceEmailSetting(context.Context, *TestInstanceEmailSettingRequest) (*emptypb.Empty, error)
|
||||
mustEmbedUnimplementedInstanceServiceServer()
|
||||
}
|
||||
|
||||
@@ -103,6 +119,9 @@ func (UnimplementedInstanceServiceServer) GetInstanceSetting(context.Context, *G
|
||||
func (UnimplementedInstanceServiceServer) UpdateInstanceSetting(context.Context, *UpdateInstanceSettingRequest) (*InstanceSetting, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateInstanceSetting not implemented")
|
||||
}
|
||||
func (UnimplementedInstanceServiceServer) TestInstanceEmailSetting(context.Context, *TestInstanceEmailSettingRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method TestInstanceEmailSetting not implemented")
|
||||
}
|
||||
func (UnimplementedInstanceServiceServer) mustEmbedUnimplementedInstanceServiceServer() {}
|
||||
func (UnimplementedInstanceServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -178,6 +197,24 @@ func _InstanceService_UpdateInstanceSetting_Handler(srv interface{}, ctx context
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _InstanceService_TestInstanceEmailSetting_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(TestInstanceEmailSettingRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(InstanceServiceServer).TestInstanceEmailSetting(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: InstanceService_TestInstanceEmailSetting_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(InstanceServiceServer).TestInstanceEmailSetting(ctx, req.(*TestInstanceEmailSettingRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// InstanceService_ServiceDesc is the grpc.ServiceDesc for InstanceService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -197,6 +234,10 @@ var InstanceService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "UpdateInstanceSetting",
|
||||
Handler: _InstanceService_UpdateInstanceSetting_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "TestInstanceEmailSetting",
|
||||
Handler: _InstanceService_TestInstanceEmailSetting_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "api/v1/instance_service.proto",
|
||||
|
||||
@@ -2797,7 +2797,7 @@ type UserStats_MemoTypeStats struct {
|
||||
|
||||
func (x *UserStats_MemoTypeStats) Reset() {
|
||||
*x = UserStats_MemoTypeStats{}
|
||||
mi := &file_api_v1_user_service_proto_msgTypes[41]
|
||||
mi := &file_api_v1_user_service_proto_msgTypes[42]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -2809,7 +2809,7 @@ func (x *UserStats_MemoTypeStats) String() string {
|
||||
func (*UserStats_MemoTypeStats) ProtoMessage() {}
|
||||
|
||||
func (x *UserStats_MemoTypeStats) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_user_service_proto_msgTypes[41]
|
||||
mi := &file_api_v1_user_service_proto_msgTypes[42]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2822,7 +2822,7 @@ func (x *UserStats_MemoTypeStats) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use UserStats_MemoTypeStats.ProtoReflect.Descriptor instead.
|
||||
func (*UserStats_MemoTypeStats) Descriptor() ([]byte, []int) {
|
||||
return file_api_v1_user_service_proto_rawDescGZIP(), []int{9, 0}
|
||||
return file_api_v1_user_service_proto_rawDescGZIP(), []int{9, 1}
|
||||
}
|
||||
|
||||
func (x *UserStats_MemoTypeStats) GetLinkCount() int32 {
|
||||
@@ -3179,7 +3179,10 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
||||
"\ttag_count\x18\x04 \x03(\v2%.memos.api.v1.UserStats.TagCountEntryR\btagCount\x12R\n" +
|
||||
"\x17memo_created_timestamps\x18\a \x03(\v2\x1a.google.protobuf.TimestampR\x15memoCreatedTimestamps\x12!\n" +
|
||||
"\fpinned_memos\x18\x05 \x03(\tR\vpinnedMemos\x12(\n" +
|
||||
"\x10total_memo_count\x18\x06 \x01(\x05R\x0etotalMemoCount\x1a\x8b\x01\n" +
|
||||
"\x10total_memo_count\x18\x06 \x01(\x05R\x0etotalMemoCount\x1a;\n" +
|
||||
"\rTagCountEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01\x1a\x8b\x01\n" +
|
||||
"\rMemoTypeStats\x12\x1d\n" +
|
||||
"\n" +
|
||||
"link_count\x18\x01 \x01(\x05R\tlinkCount\x12\x1d\n" +
|
||||
@@ -3188,10 +3191,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"todo_count\x18\x03 \x01(\x05R\ttodoCount\x12\x1d\n" +
|
||||
"\n" +
|
||||
"undo_count\x18\x04 \x01(\x05R\tundoCount\x1a;\n" +
|
||||
"\rTagCountEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01:?\xeaA<\n" +
|
||||
"undo_count\x18\x04 \x01(\x05R\tundoCount:?\xeaA<\n" +
|
||||
"\x16memos.api.v1/UserStats\x12\fusers/{user}*\tuserStats2\tuserStatsJ\x04\b\x02\x10\x03R\x17memo_display_timestamps\"D\n" +
|
||||
"\x13GetUserStatsRequest\x12-\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
|
||||
@@ -3453,8 +3453,8 @@ var file_api_v1_user_service_proto_goTypes = []any{
|
||||
(*ListUserNotificationsResponse)(nil), // 42: memos.api.v1.ListUserNotificationsResponse
|
||||
(*UpdateUserNotificationRequest)(nil), // 43: memos.api.v1.UpdateUserNotificationRequest
|
||||
(*DeleteUserNotificationRequest)(nil), // 44: memos.api.v1.DeleteUserNotificationRequest
|
||||
(*UserStats_MemoTypeStats)(nil), // 45: memos.api.v1.UserStats.MemoTypeStats
|
||||
nil, // 46: memos.api.v1.UserStats.TagCountEntry
|
||||
nil, // 45: memos.api.v1.UserStats.TagCountEntry
|
||||
(*UserStats_MemoTypeStats)(nil), // 46: memos.api.v1.UserStats.MemoTypeStats
|
||||
(*UserSetting_GeneralSetting)(nil), // 47: memos.api.v1.UserSetting.GeneralSetting
|
||||
(*UserSetting_WebhooksSetting)(nil), // 48: memos.api.v1.UserSetting.WebhooksSetting
|
||||
(*UserNotification_MemoCommentPayload)(nil), // 49: memos.api.v1.UserNotification.MemoCommentPayload
|
||||
@@ -3475,8 +3475,8 @@ var file_api_v1_user_service_proto_depIdxs = []int32{
|
||||
4, // 7: memos.api.v1.CreateUserRequest.user:type_name -> memos.api.v1.User
|
||||
4, // 8: memos.api.v1.UpdateUserRequest.user:type_name -> memos.api.v1.User
|
||||
53, // 9: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
45, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
|
||||
46, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
|
||||
46, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
|
||||
45, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
|
||||
52, // 12: memos.api.v1.UserStats.memo_created_timestamps:type_name -> google.protobuf.Timestamp
|
||||
13, // 13: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats
|
||||
47, // 14: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting
|
||||
|
||||
@@ -476,6 +476,28 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Status'
|
||||
/api/v1/instance/settings/notification:testEmail:
|
||||
post:
|
||||
tags:
|
||||
- InstanceService
|
||||
description: Tests notification email delivery with the provided or stored SMTP settings.
|
||||
operationId: InstanceService_TestInstanceEmailSetting
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TestInstanceEmailSettingRequest'
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content: {}
|
||||
default:
|
||||
description: Default error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Status'
|
||||
/api/v1/instance/{instance}/*:
|
||||
get:
|
||||
tags:
|
||||
@@ -3207,6 +3229,7 @@ components:
|
||||
smtpUsername:
|
||||
type: string
|
||||
smtpPassword:
|
||||
writeOnly: true
|
||||
type: string
|
||||
fromEmail:
|
||||
type: string
|
||||
@@ -3447,6 +3470,7 @@ components:
|
||||
accessKeyId:
|
||||
type: string
|
||||
accessKeySecret:
|
||||
writeOnly: true
|
||||
type: string
|
||||
endpoint:
|
||||
type: string
|
||||
@@ -3459,6 +3483,17 @@ components:
|
||||
description: |-
|
||||
S3 configuration for cloud storage backend.
|
||||
Reference: https://developers.cloudflare.com/r2/examples/aws/aws-sdk-go/
|
||||
TestInstanceEmailSettingRequest:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/NotificationSetting_EmailSetting'
|
||||
description: Optional. SMTP email settings to test. If omitted, the stored notification email setting is used.
|
||||
recipientEmail:
|
||||
type: string
|
||||
description: Optional. Recipient email address. If omitted, the current user's email address is used.
|
||||
description: Request message for TestInstanceEmailSetting method.
|
||||
TranscribeRequest:
|
||||
required:
|
||||
- providerId
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -46,6 +46,7 @@ func TestProtectedMethodsRequireAuth(t *testing.T) {
|
||||
"/memos.api.v1.AuthService/GetCurrentUser",
|
||||
// Instance Service - admin operations
|
||||
"/memos.api.v1.InstanceService/UpdateInstanceSetting",
|
||||
"/memos.api.v1.InstanceService/TestInstanceEmailSetting",
|
||||
// User Service - modification operations
|
||||
"/memos.api.v1.UserService/ListUsers",
|
||||
"/memos.api.v1.UserService/UpdateUser",
|
||||
|
||||
@@ -39,6 +39,14 @@ func (s *ConnectServiceHandler) UpdateInstanceSetting(ctx context.Context, req *
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *ConnectServiceHandler) TestInstanceEmailSetting(ctx context.Context, req *connect.Request[v1pb.TestInstanceEmailSettingRequest]) (*connect.Response[emptypb.Empty], error) {
|
||||
resp, err := s.APIV1Service.TestInstanceEmailSetting(ctx, req.Msg)
|
||||
if err != nil {
|
||||
return nil, convertGRPCError(err)
|
||||
}
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
// AuthService
|
||||
//
|
||||
// Auth service methods need special handling for response headers (cookies).
|
||||
|
||||
@@ -12,9 +12,11 @@ import (
|
||||
colorpb "google.golang.org/genproto/googleapis/type/color"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/server/notification"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
@@ -130,6 +132,9 @@ func (s *APIV1Service) UpdateInstanceSetting(ctx context.Context, request *v1pb.
|
||||
if notif := updateSetting.GetNotificationSetting(); notif != nil && notif.Email != nil && notif.Email.SmtpPassword == "" {
|
||||
existing, err := s.Store.GetInstanceNotificationSetting(ctx)
|
||||
if err == nil && existing != nil && existing.Email != nil {
|
||||
if existing.Email.SmtpPassword != "" && !sameSMTPConnectionIdentity(notif.Email, existing.Email) {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "smtp password is required when changing SMTP host, port, username, or encryption settings")
|
||||
}
|
||||
notif.Email.SmtpPassword = existing.Email.SmtpPassword
|
||||
}
|
||||
}
|
||||
@@ -156,6 +161,82 @@ func (s *APIV1Service) UpdateInstanceSetting(ctx context.Context, request *v1pb.
|
||||
return convertInstanceSettingFromStore(instanceSetting), nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) TestInstanceEmailSetting(ctx context.Context, request *v1pb.TestInstanceEmailSettingRequest) (*emptypb.Empty, error) {
|
||||
user, err := s.fetchCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
||||
}
|
||||
if user.Role != store.RoleAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
emailSetting, err := s.resolveTestEmailSetting(ctx, request.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recipientEmail := strings.TrimSpace(request.RecipientEmail)
|
||||
if recipientEmail == "" {
|
||||
recipientEmail = strings.TrimSpace(user.Email)
|
||||
}
|
||||
if recipientEmail == "" {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "recipient email is required")
|
||||
}
|
||||
|
||||
if err := notification.ValidateEmailSetting(emailSetting); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid notification email setting: %v", err)
|
||||
}
|
||||
|
||||
if err := notification.SendTestEmail(emailSetting, recipientEmail); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to send test email: %v. Check that the SMTP port matches encryption: Gmail uses port 587 with STARTTLS on and SSL/TLS off; port 465 requires SSL/TLS on", err)
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) resolveTestEmailSetting(ctx context.Context, requestEmail *v1pb.InstanceSetting_NotificationSetting_EmailSetting) (*storepb.InstanceNotificationSetting_EmailSetting, error) {
|
||||
if requestEmail == nil {
|
||||
existing, err := s.Store.GetInstanceNotificationSetting(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get notification setting: %v", err)
|
||||
}
|
||||
return existing.GetEmail(), nil
|
||||
}
|
||||
|
||||
emailSetting := convertInstanceNotificationSettingToStore(&v1pb.InstanceSetting_NotificationSetting{Email: requestEmail}).GetEmail()
|
||||
if emailSetting.SmtpPassword != "" {
|
||||
return emailSetting, nil
|
||||
}
|
||||
|
||||
existing, err := s.Store.GetInstanceNotificationSetting(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get notification setting: %v", err)
|
||||
}
|
||||
existingEmail := existing.GetEmail()
|
||||
if existingEmail == nil || existingEmail.SmtpPassword == "" {
|
||||
return emailSetting, nil
|
||||
}
|
||||
if sameSMTPConnectionIdentity(emailSetting, existingEmail) {
|
||||
emailSetting.SmtpPassword = existingEmail.SmtpPassword
|
||||
return emailSetting, nil
|
||||
}
|
||||
return nil, status.Errorf(codes.InvalidArgument, "smtp password is required when changing SMTP host, port, username, or encryption settings")
|
||||
}
|
||||
|
||||
func sameSMTPConnectionIdentity(setting, existing *storepb.InstanceNotificationSetting_EmailSetting) bool {
|
||||
if setting == nil || existing == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(setting.SmtpHost) == strings.TrimSpace(existing.SmtpHost) &&
|
||||
setting.SmtpPort == existing.SmtpPort &&
|
||||
strings.TrimSpace(setting.SmtpUsername) == strings.TrimSpace(existing.SmtpUsername) &&
|
||||
setting.UseTls == existing.UseTls &&
|
||||
setting.UseSsl == existing.UseSsl
|
||||
}
|
||||
|
||||
func convertInstanceSettingFromStore(setting *storepb.InstanceSetting) *v1pb.InstanceSetting {
|
||||
instanceSetting := &v1pb.InstanceSetting{
|
||||
Name: fmt.Sprintf("instance/settings/%s", setting.Key.String()),
|
||||
|
||||
@@ -121,7 +121,7 @@ func (s *APIV1Service) dispatchMemoMentionNotifications(ctx context.Context, mem
|
||||
payload.RelatedMemoId = relatedMemo.ID
|
||||
}
|
||||
|
||||
if _, err := s.Store.CreateInbox(ctx, &store.Inbox{
|
||||
if _, err := s.createInboxWithEmailNotification(ctx, &store.Inbox{
|
||||
SenderID: memo.CreatorID,
|
||||
ReceiverID: target.ID,
|
||||
Status: store.UNREAD,
|
||||
|
||||
@@ -678,7 +678,7 @@ func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.Crea
|
||||
}
|
||||
creatorID := creator.ID
|
||||
if memoComment.Visibility != v1pb.Visibility_PRIVATE && creatorID != relatedMemo.CreatorID {
|
||||
if _, err := s.Store.CreateInbox(ctx, &store.Inbox{
|
||||
if _, err := s.createInboxWithEmailNotification(ctx, &store.Inbox{
|
||||
SenderID: creatorID,
|
||||
ReceiverID: relatedMemo.CreatorID,
|
||||
Status: store.UNREAD,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
)
|
||||
|
||||
func TestGetInstanceProfile(t *testing.T) {
|
||||
@@ -287,6 +288,77 @@ func TestGetInstanceSetting(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestTestInstanceEmailSettingAuthorization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
admin, err := ts.CreateHostUser(ctx, "email-test-admin")
|
||||
require.NoError(t, err)
|
||||
adminCtx := ts.CreateUserContext(ctx, admin.ID)
|
||||
|
||||
regularUser, err := ts.CreateRegularUser(ctx, "email-test-user")
|
||||
require.NoError(t, err)
|
||||
userCtx := ts.CreateUserContext(ctx, regularUser.ID)
|
||||
|
||||
req := &v1pb.TestInstanceEmailSettingRequest{}
|
||||
|
||||
_, err = ts.Service.TestInstanceEmailSetting(ctx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "not authenticated")
|
||||
|
||||
_, err = ts.Service.TestInstanceEmailSetting(userCtx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "permission denied")
|
||||
|
||||
_, err = ts.Service.TestInstanceEmailSetting(adminCtx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid notification email setting")
|
||||
}
|
||||
|
||||
func TestTestInstanceEmailSettingRequiresPasswordWhenSMTPIdentityChanges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
admin, err := ts.CreateHostUser(ctx, "email-test-identity-admin")
|
||||
require.NoError(t, err)
|
||||
adminCtx := ts.CreateUserContext(ctx, admin.ID)
|
||||
|
||||
_, err = ts.Store.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
|
||||
Key: storepb.InstanceSettingKey_NOTIFICATION,
|
||||
Value: &storepb.InstanceSetting_NotificationSetting{
|
||||
NotificationSetting: &storepb.InstanceNotificationSetting{
|
||||
Email: &storepb.InstanceNotificationSetting_EmailSetting{
|
||||
Enabled: true,
|
||||
SmtpHost: "smtp.example.com",
|
||||
SmtpPort: 587,
|
||||
SmtpUsername: "bot@example.com",
|
||||
SmtpPassword: "stored-password",
|
||||
FromEmail: "bot@example.com",
|
||||
UseTls: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ts.Service.TestInstanceEmailSetting(adminCtx, &v1pb.TestInstanceEmailSettingRequest{
|
||||
Email: &v1pb.InstanceSetting_NotificationSetting_EmailSetting{
|
||||
Enabled: true,
|
||||
SmtpHost: "attacker.example.com",
|
||||
SmtpPort: 587,
|
||||
SmtpUsername: "bot@example.com",
|
||||
SmtpPassword: "",
|
||||
FromEmail: "bot@example.com",
|
||||
UseTls: true,
|
||||
},
|
||||
RecipientEmail: admin.Email,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp password is required")
|
||||
}
|
||||
|
||||
func TestUpdateInstanceSetting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -481,7 +553,7 @@ func TestUpdateInstanceSetting(t *testing.T) {
|
||||
|
||||
// Second update with an empty password (simulating a UI that doesn't re-send the secret).
|
||||
notificationSetting.GetNotificationSetting().GetEmail().SmtpPassword = ""
|
||||
notificationSetting.GetNotificationSetting().GetEmail().SmtpHost = "smtp2.example.com"
|
||||
notificationSetting.GetNotificationSetting().GetEmail().FromName = "Updated Bot"
|
||||
_, err = ts.Service.UpdateInstanceSetting(adminCtx, &v1pb.UpdateInstanceSettingRequest{
|
||||
Setting: notificationSetting,
|
||||
})
|
||||
@@ -492,7 +564,46 @@ func TestUpdateInstanceSetting(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "original-password", stored.GetEmail().GetSmtpPassword(),
|
||||
"existing SmtpPassword must be preserved when an empty value is sent")
|
||||
require.Equal(t, "smtp2.example.com", stored.GetEmail().GetSmtpHost())
|
||||
require.Equal(t, "Updated Bot", stored.GetEmail().GetFromName())
|
||||
})
|
||||
|
||||
t.Run("UpdateInstanceSetting - empty password rejected when SMTP identity changes", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
hostUser, err := ts.CreateHostUser(ctx, "admin")
|
||||
require.NoError(t, err)
|
||||
adminCtx := ts.CreateUserContext(ctx, hostUser.ID)
|
||||
|
||||
notificationSetting := &v1pb.InstanceSetting{
|
||||
Name: "instance/settings/NOTIFICATION",
|
||||
Value: &v1pb.InstanceSetting_NotificationSetting_{
|
||||
NotificationSetting: &v1pb.InstanceSetting_NotificationSetting{
|
||||
Email: &v1pb.InstanceSetting_NotificationSetting_EmailSetting{
|
||||
Enabled: true,
|
||||
SmtpHost: "smtp.example.com",
|
||||
SmtpPort: 587,
|
||||
SmtpUsername: "bot@example.com",
|
||||
SmtpPassword: "original-password",
|
||||
FromEmail: "bot@example.com",
|
||||
UseTls: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = ts.Service.UpdateInstanceSetting(adminCtx, &v1pb.UpdateInstanceSettingRequest{
|
||||
Setting: notificationSetting,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
notificationSetting.GetNotificationSetting().GetEmail().SmtpPassword = ""
|
||||
notificationSetting.GetNotificationSetting().GetEmail().SmtpHost = "smtp2.example.com"
|
||||
_, err = ts.Service.UpdateInstanceSetting(adminCtx, &v1pb.UpdateInstanceSettingRequest{
|
||||
Setting: notificationSetting,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp password is required")
|
||||
})
|
||||
|
||||
t.Run("UpdateInstanceSetting - S3 secret is write-only and preserved on empty", func(t *testing.T) {
|
||||
|
||||
@@ -3,11 +3,13 @@ package test
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
|
||||
"github.com/usememos/memos/internal/email"
|
||||
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
@@ -105,6 +107,185 @@ func TestListUserNotificationsStoresMemoCommentPayloadInInbox(t *testing.T) {
|
||||
require.NotZero(t, inboxes[0].Message.GetMemoComment().RelatedMemoId)
|
||||
}
|
||||
|
||||
func TestCreateMemoCommentSendsEmailNotificationWhenEnabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
var sentConfig *email.Config
|
||||
var sentMessage *email.Message
|
||||
ts.Service.NotificationEmailSender = func(config *email.Config, message *email.Message) {
|
||||
sentConfig = config
|
||||
sentMessage = message
|
||||
}
|
||||
|
||||
_, err := ts.Store.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
|
||||
Key: storepb.InstanceSettingKey_NOTIFICATION,
|
||||
Value: &storepb.InstanceSetting_NotificationSetting{
|
||||
NotificationSetting: &storepb.InstanceNotificationSetting{
|
||||
Email: &storepb.InstanceNotificationSetting_EmailSetting{
|
||||
Enabled: true,
|
||||
SmtpHost: "smtp.example.com",
|
||||
SmtpPort: 587,
|
||||
SmtpUsername: "bot@example.com",
|
||||
SmtpPassword: "password",
|
||||
FromEmail: "bot@example.com",
|
||||
FromName: "Memos",
|
||||
ReplyTo: "reply@example.com",
|
||||
UseTls: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
owner, err := ts.CreateRegularUser(ctx, "email-comment-owner")
|
||||
require.NoError(t, err)
|
||||
ownerCtx := ts.CreateUserContext(ctx, owner.ID)
|
||||
|
||||
commenter, err := ts.CreateRegularUser(ctx, "email-commenter")
|
||||
require.NoError(t, err)
|
||||
commenterCtx := ts.CreateUserContext(ctx, commenter.ID)
|
||||
|
||||
memo, err := ts.Service.CreateMemo(ownerCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{
|
||||
Content: "Base memo for email",
|
||||
Visibility: apiv1.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
comment, err := ts.Service.CreateMemoComment(commenterCtx, &apiv1.CreateMemoCommentRequest{
|
||||
Name: memo.Name,
|
||||
Comment: &apiv1.Memo{
|
||||
Content: "Email comment content",
|
||||
Visibility: apiv1.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, sentConfig)
|
||||
require.Equal(t, "smtp.example.com", sentConfig.SMTPHost)
|
||||
require.Equal(t, 587, sentConfig.SMTPPort)
|
||||
require.Equal(t, "bot@example.com", sentConfig.FromEmail)
|
||||
require.True(t, sentConfig.UseTLS)
|
||||
|
||||
require.NotNil(t, sentMessage)
|
||||
require.Equal(t, []string{owner.Email}, sentMessage.To)
|
||||
require.Equal(t, "reply@example.com", sentMessage.ReplyTo)
|
||||
require.Contains(t, sentMessage.Subject, "commented on your memo")
|
||||
require.Contains(t, sentMessage.Body, "Hi email-comment-owner,")
|
||||
require.Contains(t, sentMessage.Body, "email-commenter commented on your memo.")
|
||||
require.Contains(t, sentMessage.Body, fmt.Sprintf("http://localhost:8080/%s#%s", memo.Name, strings.TrimPrefix(comment.Name, "memos/")))
|
||||
require.Contains(t, sentMessage.Body, "You are receiving this because you own this memo.")
|
||||
require.NotContains(t, sentMessage.Body, "Email comment content")
|
||||
require.NotContains(t, sentMessage.Body, "Base memo for email")
|
||||
}
|
||||
|
||||
func TestCreateMemoMentionSendsEmailNotificationWhenEnabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
var sentMessage *email.Message
|
||||
ts.Service.NotificationEmailSender = func(_ *email.Config, message *email.Message) {
|
||||
sentMessage = message
|
||||
}
|
||||
|
||||
_, err := ts.Store.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
|
||||
Key: storepb.InstanceSettingKey_NOTIFICATION,
|
||||
Value: &storepb.InstanceSetting_NotificationSetting{
|
||||
NotificationSetting: &storepb.InstanceNotificationSetting{
|
||||
Email: &storepb.InstanceNotificationSetting_EmailSetting{
|
||||
Enabled: true,
|
||||
SmtpHost: "smtp.example.com",
|
||||
SmtpPort: 587,
|
||||
FromEmail: "bot@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
author, err := ts.CreateRegularUser(ctx, "email-mention-author")
|
||||
require.NoError(t, err)
|
||||
authorCtx := ts.CreateUserContext(ctx, author.ID)
|
||||
|
||||
target, err := ts.CreateRegularUser(ctx, "email-mention-target")
|
||||
require.NoError(t, err)
|
||||
|
||||
memo, err := ts.Service.CreateMemo(authorCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{
|
||||
Content: fmt.Sprintf("Hello @%s from email", target.Username),
|
||||
Visibility: apiv1.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, sentMessage)
|
||||
require.Equal(t, []string{target.Email}, sentMessage.To)
|
||||
require.Contains(t, sentMessage.Subject, "mentioned you in a memo")
|
||||
require.Contains(t, sentMessage.Body, "Hi email-mention-target,")
|
||||
require.Contains(t, sentMessage.Body, "email-mention-author mentioned you in a memo.")
|
||||
require.Contains(t, sentMessage.Body, fmt.Sprintf("http://localhost:8080/%s", memo.Name))
|
||||
require.Contains(t, sentMessage.Body, "You are receiving this because you were mentioned in this memo.")
|
||||
require.NotContains(t, sentMessage.Body, "Hello")
|
||||
require.NotContains(t, sentMessage.Body, fmt.Sprintf("Hello @%s from email", target.Username))
|
||||
}
|
||||
|
||||
func TestCreateMemoCommentSkipsEmailNotificationWithoutInstanceURL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
ts.Profile.InstanceURL = ""
|
||||
|
||||
var sentMessage *email.Message
|
||||
ts.Service.NotificationEmailSender = func(_ *email.Config, message *email.Message) {
|
||||
sentMessage = message
|
||||
}
|
||||
|
||||
_, err := ts.Store.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
|
||||
Key: storepb.InstanceSettingKey_NOTIFICATION,
|
||||
Value: &storepb.InstanceSetting_NotificationSetting{
|
||||
NotificationSetting: &storepb.InstanceNotificationSetting{
|
||||
Email: &storepb.InstanceNotificationSetting_EmailSetting{
|
||||
Enabled: true,
|
||||
SmtpHost: "smtp.example.com",
|
||||
SmtpPort: 587,
|
||||
FromEmail: "bot@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
owner, err := ts.CreateRegularUser(ctx, "email-comment-no-url-owner")
|
||||
require.NoError(t, err)
|
||||
ownerCtx := ts.CreateUserContext(ctx, owner.ID)
|
||||
|
||||
commenter, err := ts.CreateRegularUser(ctx, "email-comment-no-url-commenter")
|
||||
require.NoError(t, err)
|
||||
commenterCtx := ts.CreateUserContext(ctx, commenter.ID)
|
||||
|
||||
memo, err := ts.Service.CreateMemo(ownerCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{
|
||||
Content: "Base memo without instance URL",
|
||||
Visibility: apiv1.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ts.Service.CreateMemoComment(commenterCtx, &apiv1.CreateMemoCommentRequest{
|
||||
Name: memo.Name,
|
||||
Comment: &apiv1.Memo{
|
||||
Content: "Comment without instance URL",
|
||||
Visibility: apiv1.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, sentMessage)
|
||||
}
|
||||
|
||||
func TestListUserNotificationsOmitsPayloadWhenMemosDeleted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/usememos/memos/internal/profile"
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
"github.com/usememos/memos/server/auth"
|
||||
"github.com/usememos/memos/server/notification"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
@@ -31,11 +32,12 @@ type APIV1Service struct {
|
||||
v1pb.UnimplementedShortcutServiceServer
|
||||
v1pb.UnimplementedIdentityProviderServiceServer
|
||||
|
||||
Secret string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
MarkdownService markdown.Service
|
||||
SSEHub *SSEHub
|
||||
Secret string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
MarkdownService markdown.Service
|
||||
SSEHub *SSEHub
|
||||
NotificationEmailSender notification.EmailSender
|
||||
|
||||
// thumbnailSemaphore limits concurrent thumbnail generation to prevent memory exhaustion
|
||||
thumbnailSemaphore *semaphore.Weighted
|
||||
@@ -53,6 +55,7 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store
|
||||
Store: store,
|
||||
MarkdownService: markdownService,
|
||||
SSEHub: NewSSEHub(),
|
||||
NotificationEmailSender: nil,
|
||||
thumbnailSemaphore: semaphore.NewWeighted(3), // Limit to 3 concurrent thumbnail generations
|
||||
imageProcessingSemaphore: semaphore.NewWeighted(2),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -4,7 +4,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SettingRowProps {
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
children: React.ReactNode;
|
||||
@@ -23,7 +23,7 @@ const SettingRow: React.FC<SettingRowProps> = ({ label, description, tooltip, ch
|
||||
>
|
||||
<div className={cn("flex min-w-0 flex-col gap-1", vertical ? "w-full" : "flex-1")}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("text-sm", vertical ? "font-medium" : "")}>{label}</span>
|
||||
<div className={cn("text-sm", vertical ? "font-medium" : "")}>{label}</div>
|
||||
{tooltip && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
KeyIcon,
|
||||
LibraryIcon,
|
||||
type LucideIcon,
|
||||
MailIcon,
|
||||
Settings2Icon,
|
||||
TagsIcon,
|
||||
UserIcon,
|
||||
@@ -17,6 +18,7 @@ import InstanceSection from "@/components/Settings/InstanceSection";
|
||||
import MemberSection from "@/components/Settings/MemberSection";
|
||||
import MemoRelatedSettings from "@/components/Settings/MemoRelatedSettings";
|
||||
import MyAccountSection from "@/components/Settings/MyAccountSection";
|
||||
import NotificationSection from "@/components/Settings/NotificationSection";
|
||||
import PreferencesSection from "@/components/Settings/PreferencesSection";
|
||||
import SSOSection from "@/components/Settings/SSOSection";
|
||||
import StorageSection from "@/components/Settings/StorageSection";
|
||||
@@ -24,7 +26,18 @@ import TagsSection from "@/components/Settings/TagsSection";
|
||||
import WebhookSection from "@/components/Settings/WebhookSection";
|
||||
import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
|
||||
|
||||
export type SettingSectionKey = "my-account" | "preference" | "webhook" | "member" | "system" | "memo" | "storage" | "sso" | "tags" | "ai";
|
||||
export type SettingSectionKey =
|
||||
| "my-account"
|
||||
| "preference"
|
||||
| "webhook"
|
||||
| "member"
|
||||
| "system"
|
||||
| "memo"
|
||||
| "storage"
|
||||
| "notification"
|
||||
| "sso"
|
||||
| "tags"
|
||||
| "ai";
|
||||
|
||||
type SettingSectionScope = "basic" | "admin";
|
||||
|
||||
@@ -96,6 +109,14 @@ export const SETTINGS_SECTIONS: SettingSectionDefinition[] = [
|
||||
component: StorageSection,
|
||||
preloadSettingKeys: [InstanceSetting_Key.STORAGE],
|
||||
},
|
||||
{
|
||||
key: "notification",
|
||||
scope: "admin",
|
||||
labelKey: "setting.notification.label",
|
||||
icon: MailIcon,
|
||||
component: NotificationSection,
|
||||
preloadSettingKeys: [InstanceSetting_Key.NOTIFICATION],
|
||||
},
|
||||
{
|
||||
key: "sso",
|
||||
scope: "admin",
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
InstanceSetting_Key,
|
||||
InstanceSetting_MemoRelatedSetting,
|
||||
InstanceSetting_MemoRelatedSettingSchema,
|
||||
InstanceSetting_NotificationSetting,
|
||||
InstanceSetting_NotificationSettingSchema,
|
||||
InstanceSetting_StorageSetting,
|
||||
InstanceSetting_StorageSettingSchema,
|
||||
InstanceSetting_TagsSetting,
|
||||
@@ -41,6 +43,7 @@ interface InstanceContextValue extends InstanceState {
|
||||
memoRelatedSetting: InstanceSetting_MemoRelatedSetting;
|
||||
storageSetting: InstanceSetting_StorageSetting;
|
||||
tagsSetting: InstanceSetting_TagsSetting;
|
||||
notificationSetting: InstanceSetting_NotificationSetting;
|
||||
aiSetting: InstanceSetting_AISetting;
|
||||
initialize: () => Promise<void>;
|
||||
fetchSetting: (key: InstanceSetting_Key) => Promise<void>;
|
||||
@@ -93,6 +96,14 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
|
||||
return create(InstanceSetting_TagsSettingSchema, {});
|
||||
}, [state.settings]);
|
||||
|
||||
const notificationSetting = useMemo((): InstanceSetting_NotificationSetting => {
|
||||
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}NOTIFICATION`);
|
||||
if (setting?.value.case === "notificationSetting") {
|
||||
return setting.value.value;
|
||||
}
|
||||
return create(InstanceSetting_NotificationSettingSchema, {});
|
||||
}, [state.settings]);
|
||||
|
||||
const aiSetting = useMemo((): InstanceSetting_AISetting => {
|
||||
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}AI`);
|
||||
if (setting?.value.case === "aiSetting") {
|
||||
@@ -148,10 +159,10 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const updateSetting = useCallback(async (setting: InstanceSetting) => {
|
||||
await instanceServiceClient.updateInstanceSetting({ setting });
|
||||
const updatedSetting = await instanceServiceClient.updateInstanceSetting({ setting });
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
settings: [...prev.settings.filter((s) => s.name !== setting.name), setting],
|
||||
settings: [...prev.settings.filter((s) => s.name !== updatedSetting.name), updatedSetting],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
@@ -163,12 +174,24 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
|
||||
memoRelatedSetting,
|
||||
storageSetting,
|
||||
tagsSetting,
|
||||
notificationSetting,
|
||||
aiSetting,
|
||||
initialize,
|
||||
fetchSetting,
|
||||
updateSetting,
|
||||
}),
|
||||
[state, generalSetting, memoRelatedSetting, storageSetting, tagsSetting, aiSetting, initialize, fetchSetting, updateSetting],
|
||||
[
|
||||
state,
|
||||
generalSetting,
|
||||
memoRelatedSetting,
|
||||
storageSetting,
|
||||
tagsSetting,
|
||||
notificationSetting,
|
||||
aiSetting,
|
||||
initialize,
|
||||
fetchSetting,
|
||||
updateSetting,
|
||||
],
|
||||
);
|
||||
|
||||
return <InstanceContext.Provider value={value}>{children}</InstanceContext.Provider>;
|
||||
|
||||
@@ -495,6 +495,41 @@
|
||||
"my-account": {
|
||||
"label": "My Account"
|
||||
},
|
||||
"notification": {
|
||||
"email-description": "Send email copies for memo comments and mentions. Gmail works with smtp.gmail.com, port 587, STARTTLS, and an app password.",
|
||||
"email-enabled": "Enable email notifications",
|
||||
"email-enabled-description": "Email is sent in addition to the in-app inbox notification. Fill the required fields before enabling this for users.",
|
||||
"email-title": "Email delivery",
|
||||
"from-email": "From email",
|
||||
"from-email-description": "Sender address shown in notification emails. For Gmail, use the same Gmail account as the SMTP username.",
|
||||
"from-name": "From name",
|
||||
"from-name-description": "Display name shown next to the sender address. Leave blank to show only the email address.",
|
||||
"label": "Notifications",
|
||||
"reply-to": "Reply-To",
|
||||
"reply-to-description": "Address for replies to notification emails. Leave blank to receive replies at the From email address.",
|
||||
"requirement-gmail": "Required for Gmail",
|
||||
"requirement-optional": "Optional",
|
||||
"requirement-recommended": "Recommended",
|
||||
"requirement-required": "Required",
|
||||
"smtp-host": "SMTP host",
|
||||
"smtp-host-description": "SMTP server hostname. Use smtp.gmail.com for Gmail.",
|
||||
"smtp-password": "SMTP password",
|
||||
"smtp-password-description": "SMTP password or app-specific token. Gmail requires a Google app password, not your regular account password.",
|
||||
"smtp-password-placeholder-existing": "Leave blank to keep current password",
|
||||
"smtp-password-preserve-description": "Leave blank to keep the existing SMTP password. Enter a new app password only when rotating credentials.",
|
||||
"smtp-port": "SMTP port",
|
||||
"smtp-port-description": "Use 587 with STARTTLS for Gmail and most providers. Use 465 only when enabling SSL/TLS.",
|
||||
"smtp-username": "SMTP username",
|
||||
"smtp-username-description": "Authentication username. Gmail requires your full Gmail address.",
|
||||
"test-email": "Send test email",
|
||||
"test-email-missing-recipient": "Set an email address on your account before sending a test email.",
|
||||
"test-email-sending": "Sending test email...",
|
||||
"test-email-success": "Test email sent to {{email}}.",
|
||||
"use-ssl": "Use SSL/TLS",
|
||||
"use-ssl-description": "Use implicit TLS, usually with port 465. Turn this off when using STARTTLS on port 587.",
|
||||
"use-tls": "Use STARTTLS",
|
||||
"use-tls-description": "Upgrade the SMTP connection with STARTTLS. Keep this on for Gmail with port 587."
|
||||
},
|
||||
"preference": {
|
||||
"appearance-description": "Choose how the app looks and which language it uses for your account.",
|
||||
"appearance-title": "Appearance",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user