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:
Steven
2026-05-01 18:48:21 +08:00
parent 35bf761b8c
commit cd4f28ae10
27 changed files with 1624 additions and 137 deletions
+37 -7
View File
@@ -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")
+25 -6
View File
@@ -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
+23
View File
@@ -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"},
+20 -2
View File
@@ -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"))
}
+144 -79
View File
@@ -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,
},
+72 -6
View File
@@ -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
)
+44 -3
View File
@@ -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",
+12 -12
View File
@@ -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
+35
View File
@@ -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
+320
View File
@@ -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
}
+1
View File
@@ -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",
+8
View File
@@ -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).
+81
View File
@@ -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()),
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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)
+8 -5
View File
@@ -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;
+2 -2
View File
@@ -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>
+22 -1
View File
@@ -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",
+26 -3
View File
@@ -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>;
+35
View File
@@ -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