diff --git a/api/grpc/emails/v1/emails.pb.go b/api/grpc/emails/v1/emails.pb.go new file mode 100644 index 0000000000..6ab02213a1 --- /dev/null +++ b/api/grpc/emails/v1/emails.pb.go @@ -0,0 +1,315 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v6.33.0 +// source: emails/v1/emails.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ContactType int32 + +const ( + ContactType_CONTACT_TYPE_UNSPECIFIED ContactType = 0 + ContactType_CONTACT_TYPE_ID ContactType = 1 + ContactType_CONTACT_TYPE_EMAIL ContactType = 2 +) + +// Enum value maps for ContactType. +var ( + ContactType_name = map[int32]string{ + 0: "CONTACT_TYPE_UNSPECIFIED", + 1: "CONTACT_TYPE_ID", + 2: "CONTACT_TYPE_EMAIL", + } + ContactType_value = map[string]int32{ + "CONTACT_TYPE_UNSPECIFIED": 0, + "CONTACT_TYPE_ID": 1, + "CONTACT_TYPE_EMAIL": 2, + } +) + +func (x ContactType) Enum() *ContactType { + p := new(ContactType) + *p = x + return p +} + +func (x ContactType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ContactType) Descriptor() protoreflect.EnumDescriptor { + return file_emails_v1_emails_proto_enumTypes[0].Descriptor() +} + +func (ContactType) Type() protoreflect.EnumType { + return &file_emails_v1_emails_proto_enumTypes[0] +} + +func (x ContactType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ContactType.Descriptor instead. +func (ContactType) EnumDescriptor() ([]byte, []int) { + return file_emails_v1_emails_proto_rawDescGZIP(), []int{0} +} + +type EmailReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + From string `protobuf:"bytes,1,opt,name=from,proto3" json:"from,omitempty"` // Sender + FromType ContactType `protobuf:"varint,2,opt,name=from_type,json=fromType,proto3,enum=emails.v1.ContactType" json:"from_type,omitempty"` // indicate sender id or email + Tos []string `protobuf:"bytes,3,rep,name=tos,proto3" json:"tos,omitempty"` // recipients + ToType ContactType `protobuf:"varint,4,opt,name=to_type,json=toType,proto3,enum=emails.v1.ContactType" json:"to_type,omitempty"` // indicate recipient id or email + Subject string `protobuf:"bytes,5,opt,name=subject,proto3" json:"subject,omitempty"` // Email subject + Content string `protobuf:"bytes,6,opt,name=content,proto3" json:"content,omitempty"` // Email content/URL + Template *string `protobuf:"bytes,10,opt,name=template,proto3,oneof" json:"template,omitempty"` // whole template for emailer + TemplateFile *string `protobuf:"bytes,11,opt,name=template_file,json=templateFile,proto3,oneof" json:"template_file,omitempty"` // template file for emailer + Options map[string]string `protobuf:"bytes,12,rep,name=options,proto3" json:"options,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // map for template + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EmailReq) Reset() { + *x = EmailReq{} + mi := &file_emails_v1_emails_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmailReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmailReq) ProtoMessage() {} + +func (x *EmailReq) ProtoReflect() protoreflect.Message { + mi := &file_emails_v1_emails_proto_msgTypes[0] + 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 EmailReq.ProtoReflect.Descriptor instead. +func (*EmailReq) Descriptor() ([]byte, []int) { + return file_emails_v1_emails_proto_rawDescGZIP(), []int{0} +} + +func (x *EmailReq) GetFrom() string { + if x != nil { + return x.From + } + return "" +} + +func (x *EmailReq) GetFromType() ContactType { + if x != nil { + return x.FromType + } + return ContactType_CONTACT_TYPE_UNSPECIFIED +} + +func (x *EmailReq) GetTos() []string { + if x != nil { + return x.Tos + } + return nil +} + +func (x *EmailReq) GetToType() ContactType { + if x != nil { + return x.ToType + } + return ContactType_CONTACT_TYPE_UNSPECIFIED +} + +func (x *EmailReq) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +func (x *EmailReq) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +func (x *EmailReq) GetTemplate() string { + if x != nil && x.Template != nil { + return *x.Template + } + return "" +} + +func (x *EmailReq) GetTemplateFile() string { + if x != nil && x.TemplateFile != nil { + return *x.TemplateFile + } + return "" +} + +func (x *EmailReq) GetOptions() map[string]string { + if x != nil { + return x.Options + } + return nil +} + +type SendEmailRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendEmailRes) Reset() { + *x = SendEmailRes{} + mi := &file_emails_v1_emails_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendEmailRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendEmailRes) ProtoMessage() {} + +func (x *SendEmailRes) ProtoReflect() protoreflect.Message { + mi := &file_emails_v1_emails_proto_msgTypes[1] + 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 SendEmailRes.ProtoReflect.Descriptor instead. +func (*SendEmailRes) Descriptor() ([]byte, []int) { + return file_emails_v1_emails_proto_rawDescGZIP(), []int{1} +} + +func (x *SendEmailRes) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +var File_emails_v1_emails_proto protoreflect.FileDescriptor + +const file_emails_v1_emails_proto_rawDesc = "" + + "\n" + + "\x16emails/v1/emails.proto\x12\temails.v1\"\xac\x03\n" + + "\bEmailReq\x12\x12\n" + + "\x04from\x18\x01 \x01(\tR\x04from\x123\n" + + "\tfrom_type\x18\x02 \x01(\x0e2\x16.emails.v1.ContactTypeR\bfromType\x12\x10\n" + + "\x03tos\x18\x03 \x03(\tR\x03tos\x12/\n" + + "\ato_type\x18\x04 \x01(\x0e2\x16.emails.v1.ContactTypeR\x06toType\x12\x18\n" + + "\asubject\x18\x05 \x01(\tR\asubject\x12\x18\n" + + "\acontent\x18\x06 \x01(\tR\acontent\x12\x1f\n" + + "\btemplate\x18\n" + + " \x01(\tH\x00R\btemplate\x88\x01\x01\x12(\n" + + "\rtemplate_file\x18\v \x01(\tH\x01R\ftemplateFile\x88\x01\x01\x12:\n" + + "\aoptions\x18\f \x03(\v2 .emails.v1.EmailReq.OptionsEntryR\aoptions\x1a:\n" + + "\fOptionsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\v\n" + + "\t_templateB\x10\n" + + "\x0e_template_file\"$\n" + + "\fSendEmailRes\x12\x14\n" + + "\x05error\x18\x01 \x01(\tR\x05error*X\n" + + "\vContactType\x12\x1c\n" + + "\x18CONTACT_TYPE_UNSPECIFIED\x10\x00\x12\x13\n" + + "\x0fCONTACT_TYPE_ID\x10\x01\x12\x16\n" + + "\x12CONTACT_TYPE_EMAIL\x10\x022K\n" + + "\fEmailService\x12;\n" + + "\tSendEmail\x12\x13.emails.v1.EmailReq\x1a\x17.emails.v1.SendEmailRes\"\x00B/Z-github.com/absmach/supermq/api/grpc/emails/v1b\x06proto3" + +var ( + file_emails_v1_emails_proto_rawDescOnce sync.Once + file_emails_v1_emails_proto_rawDescData []byte +) + +func file_emails_v1_emails_proto_rawDescGZIP() []byte { + file_emails_v1_emails_proto_rawDescOnce.Do(func() { + file_emails_v1_emails_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_emails_v1_emails_proto_rawDesc), len(file_emails_v1_emails_proto_rawDesc))) + }) + return file_emails_v1_emails_proto_rawDescData +} + +var file_emails_v1_emails_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_emails_v1_emails_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_emails_v1_emails_proto_goTypes = []any{ + (ContactType)(0), // 0: emails.v1.ContactType + (*EmailReq)(nil), // 1: emails.v1.EmailReq + (*SendEmailRes)(nil), // 2: emails.v1.SendEmailRes + nil, // 3: emails.v1.EmailReq.OptionsEntry +} +var file_emails_v1_emails_proto_depIdxs = []int32{ + 0, // 0: emails.v1.EmailReq.from_type:type_name -> emails.v1.ContactType + 0, // 1: emails.v1.EmailReq.to_type:type_name -> emails.v1.ContactType + 3, // 2: emails.v1.EmailReq.options:type_name -> emails.v1.EmailReq.OptionsEntry + 1, // 3: emails.v1.EmailService.SendEmail:input_type -> emails.v1.EmailReq + 2, // 4: emails.v1.EmailService.SendEmail:output_type -> emails.v1.SendEmailRes + 4, // [4:5] is the sub-list for method output_type + 3, // [3:4] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_emails_v1_emails_proto_init() } +func file_emails_v1_emails_proto_init() { + if File_emails_v1_emails_proto != nil { + return + } + file_emails_v1_emails_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_emails_v1_emails_proto_rawDesc), len(file_emails_v1_emails_proto_rawDesc)), + NumEnums: 1, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_emails_v1_emails_proto_goTypes, + DependencyIndexes: file_emails_v1_emails_proto_depIdxs, + EnumInfos: file_emails_v1_emails_proto_enumTypes, + MessageInfos: file_emails_v1_emails_proto_msgTypes, + }.Build() + File_emails_v1_emails_proto = out.File + file_emails_v1_emails_proto_goTypes = nil + file_emails_v1_emails_proto_depIdxs = nil +} diff --git a/api/grpc/emails/v1/emails_grpc.pb.go b/api/grpc/emails/v1/emails_grpc.pb.go new file mode 100644 index 0000000000..cc207d00f1 --- /dev/null +++ b/api/grpc/emails/v1/emails_grpc.pb.go @@ -0,0 +1,130 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v6.33.0 +// source: emails/v1/emails.proto + +package v1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + EmailService_SendEmail_FullMethodName = "/emails.v1.EmailService/SendEmail" +) + +// EmailServiceClient is the client API for EmailService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// EmailService is a service that provides email-related functionalities +// for SuperMQ services. +type EmailServiceClient interface { + SendEmail(ctx context.Context, in *EmailReq, opts ...grpc.CallOption) (*SendEmailRes, error) +} + +type emailServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewEmailServiceClient(cc grpc.ClientConnInterface) EmailServiceClient { + return &emailServiceClient{cc} +} + +func (c *emailServiceClient) SendEmail(ctx context.Context, in *EmailReq, opts ...grpc.CallOption) (*SendEmailRes, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SendEmailRes) + err := c.cc.Invoke(ctx, EmailService_SendEmail_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// EmailServiceServer is the server API for EmailService service. +// All implementations must embed UnimplementedEmailServiceServer +// for forward compatibility. +// +// EmailService is a service that provides email-related functionalities +// for SuperMQ services. +type EmailServiceServer interface { + SendEmail(context.Context, *EmailReq) (*SendEmailRes, error) + mustEmbedUnimplementedEmailServiceServer() +} + +// UnimplementedEmailServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedEmailServiceServer struct{} + +func (UnimplementedEmailServiceServer) SendEmail(context.Context, *EmailReq) (*SendEmailRes, error) { + return nil, status.Errorf(codes.Unimplemented, "method SendEmail not implemented") +} +func (UnimplementedEmailServiceServer) mustEmbedUnimplementedEmailServiceServer() {} +func (UnimplementedEmailServiceServer) testEmbeddedByValue() {} + +// UnsafeEmailServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to EmailServiceServer will +// result in compilation errors. +type UnsafeEmailServiceServer interface { + mustEmbedUnimplementedEmailServiceServer() +} + +func RegisterEmailServiceServer(s grpc.ServiceRegistrar, srv EmailServiceServer) { + // If the following call pancis, it indicates UnimplementedEmailServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&EmailService_ServiceDesc, srv) +} + +func _EmailService_SendEmail_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EmailReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EmailServiceServer).SendEmail(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: EmailService_SendEmail_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EmailServiceServer).SendEmail(ctx, req.(*EmailReq)) + } + return interceptor(ctx, in, info, handler) +} + +// EmailService_ServiceDesc is the grpc.ServiceDesc for EmailService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var EmailService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "emails.v1.EmailService", + HandlerType: (*EmailServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SendEmail", + Handler: _EmailService_SendEmail_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "emails/v1/emails.proto", +} diff --git a/channels/api/grpc/endpoint_test.go b/channels/api/grpc/endpoint_test.go index fce4df2f36..761caa1ac5 100644 --- a/channels/api/grpc/endpoint_test.go +++ b/channels/api/grpc/endpoint_test.go @@ -29,8 +29,6 @@ import ( "google.golang.org/grpc/credentials/insecure" ) -const port = 7005 - var ( validID = testsutil.GenerateUUID(&testing.T{}) validChannel = ch.Channel{ @@ -40,8 +38,8 @@ var ( } ) -func startGRPCServer(svc *mocks.Service, port int) *grpc.Server { - listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) +func startGRPCServer(svc *mocks.Service) (*grpc.Server, int) { + listener, err := net.Listen("tcp", ":0") if err != nil { panic(fmt.Sprintf("failed to obtain port: %s", err)) } @@ -52,14 +50,15 @@ func startGRPCServer(svc *mocks.Service, port int) *grpc.Server { panic(fmt.Sprintf("failed to serve: %s", err)) } }() - return server + p := listener.Addr().(*net.TCPAddr).Port + return server, p } func TestAuthorize(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) @@ -135,9 +134,9 @@ func TestAuthorize(t *testing.T) { func TestRemoveClientConnections(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) @@ -174,9 +173,9 @@ func TestRemoveClientConnections(t *testing.T) { func TestUnsetParentGroupFromChannelsEndpoint(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) @@ -218,9 +217,9 @@ func TestUnsetParentGroupFromChannelsEndpoint(t *testing.T) { func TestRetrieveEntity(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) @@ -269,9 +268,9 @@ func TestRetrieveEntity(t *testing.T) { func TestRetrieveIDByRoute(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) diff --git a/clients/api/grpc/endpoint_test.go b/clients/api/grpc/endpoint_test.go index c1a6a05177..4692fa7258 100644 --- a/clients/api/grpc/endpoint_test.go +++ b/clients/api/grpc/endpoint_test.go @@ -25,8 +25,6 @@ import ( "google.golang.org/grpc/credentials/insecure" ) -const port = 7006 - var ( validID = testsutil.GenerateUUID(&testing.T{}) validSecret = "validSecret" @@ -38,8 +36,8 @@ var ( } ) -func startGRPCServer(svc *mocks.Service, port int) *grpc.Server { - listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) +func startGRPCServer(svc *mocks.Service) (*grpc.Server, int) { + listener, err := net.Listen("tcp", ":0") if err != nil { panic(fmt.Sprintf("failed to obtain port: %s", err)) } @@ -50,15 +48,15 @@ func startGRPCServer(svc *mocks.Service, port int) *grpc.Server { panic(fmt.Sprintf("failed to serve: %s", err)) } }() - - return server + p := listener.Addr().(*net.TCPAddr).Port + return server, p } func TestAuthenticate(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) @@ -107,9 +105,9 @@ func TestAuthenticate(t *testing.T) { func TestRetrieveEntity(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) @@ -163,9 +161,9 @@ func TestRetrieveEntity(t *testing.T) { func TestRetrieveEntities(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) @@ -230,9 +228,9 @@ func TestRetrieveEntities(t *testing.T) { func TestAddConnections(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) @@ -285,9 +283,9 @@ func TestAddConnections(t *testing.T) { func TestRemoveConnections(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) @@ -340,9 +338,9 @@ func TestRemoveConnections(t *testing.T) { func TestRemoveChannelConnections(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) @@ -381,9 +379,9 @@ func TestRemoveChannelConnections(t *testing.T) { func TestUnsetParentGroupFromClient(t *testing.T) { svc := new(mocks.Service) - server := startGRPCServer(svc, port) + server, p := startGRPCServer(svc) defer server.GracefulStop() - authAddr := fmt.Sprintf("localhost:%d", port) + authAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) diff --git a/cmd/domains/main.go b/cmd/domains/main.go index 08da6cb156..c0779658eb 100644 --- a/cmd/domains/main.go +++ b/cmd/domains/main.go @@ -15,6 +15,7 @@ import ( chclient "github.com/absmach/callhome/pkg/client" "github.com/absmach/supermq" grpcDomainsV1 "github.com/absmach/supermq/api/grpc/domains/v1" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" "github.com/absmach/supermq/domains" domainsSvc "github.com/absmach/supermq/domains" domainsgrpcapi "github.com/absmach/supermq/domains/api/grpc" @@ -63,6 +64,7 @@ const ( envPrefixGrpc = "SMQ_DOMAINS_GRPC_" envPrefixDB = "SMQ_DOMAINS_DB_" envPrefixAuth = "SMQ_AUTH_GRPC_" + envPrefixUsers = "SMQ_USERS_GRPC_" envPrefixDomainCallout = "SMQ_DOMAINS_CALLOUT_" defDB = "domains" defSvcHTTPPort = "9004" @@ -161,6 +163,22 @@ func main() { logger.Info("Authn successfully connected to auth gRPC server " + authnHandler.Secure()) authnMiddleware := smqauthn.NewAuthNMiddleware(authn) + usersClientConfig := grpcclient.Config{} + if err := env.ParseWithOptions(&usersClientConfig, env.Options{Prefix: envPrefixUsers}); err != nil { + logger.Error(fmt.Sprintf("failed to load users gRPC client configuration : %s", err)) + exitCode = 1 + return + } + + usersClient, usersHandler, err := grpcclient.SetupUsersClient(ctx, usersClientConfig) + if err != nil { + logger.Error(fmt.Sprintf("failed to connect to users gRPC server : %s", err.Error())) + exitCode = 1 + return + } + defer usersHandler.Close() + logger.Info("Users client successfully connected to users gRPC server " + usersHandler.Secure()) + database := postgres.NewDatabase(db, dbConfig, tracer) domainsRepo := dpostgres.NewRepository(database) @@ -208,7 +226,7 @@ func main() { return } - svc, err := newDomainService(ctx, domainsRepo, cache, tracer, cfg, authz, policyService, logger, call) + svc, err := newDomainService(ctx, domainsRepo, cache, tracer, cfg, authz, policyService, logger, call, usersClient) if err != nil { logger.Error(fmt.Sprintf("failed to create %s service: %s", svcName, err.Error())) exitCode = 1 @@ -260,7 +278,7 @@ func main() { } } -func newDomainService(ctx context.Context, domainsRepo domainsSvc.Repository, cache domainsSvc.Cache, tracer trace.Tracer, cfg config, authz authz.Authorization, policiessvc policies.Service, logger *slog.Logger, callout callout.Callout) (domains.Service, error) { +func newDomainService(ctx context.Context, domainsRepo domainsSvc.Repository, cache domainsSvc.Cache, tracer trace.Tracer, cfg config, authz authz.Authorization, policiessvc policies.Service, logger *slog.Logger, callout callout.Callout, emailClient grpcEmailsV1.EmailServiceClient) (domains.Service, error) { idProvider := uuid.New() sidProvider, err := sid.New() if err != nil { @@ -272,7 +290,7 @@ func newDomainService(ctx context.Context, domainsRepo domainsSvc.Repository, ca return nil, err } - svc, err := domainsSvc.New(domainsRepo, cache, policiessvc, idProvider, sidProvider, availableActions, builtInRoles) + svc, err := domainsSvc.New(domainsRepo, cache, policiessvc, idProvider, sidProvider, availableActions, builtInRoles, emailClient) if err != nil { return nil, fmt.Errorf("failed to init domain service: %w", err) } diff --git a/cmd/users/main.go b/cmd/users/main.go index 4f9c08e50a..4cb42de2af 100644 --- a/cmd/users/main.go +++ b/cmd/users/main.go @@ -17,6 +17,7 @@ import ( chclient "github.com/absmach/callhome/pkg/client" "github.com/absmach/supermq" grpcDomainsV1 "github.com/absmach/supermq/api/grpc/domains/v1" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" "github.com/absmach/supermq/internal/email" smqlog "github.com/absmach/supermq/logger" @@ -35,10 +36,12 @@ import ( pgclient "github.com/absmach/supermq/pkg/postgres" "github.com/absmach/supermq/pkg/prometheus" "github.com/absmach/supermq/pkg/server" + grpcserver "github.com/absmach/supermq/pkg/server/grpc" httpserver "github.com/absmach/supermq/pkg/server/http" "github.com/absmach/supermq/pkg/uuid" "github.com/absmach/supermq/users" httpapi "github.com/absmach/supermq/users/api" + usersgrpcapi "github.com/absmach/supermq/users/api/grpc" "github.com/absmach/supermq/users/emailer" "github.com/absmach/supermq/users/events" "github.com/absmach/supermq/users/hasher" @@ -53,17 +56,20 @@ import ( "golang.org/x/sync/errgroup" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/reflection" ) const ( svcName = "users" envPrefixDB = "SMQ_USERS_DB_" envPrefixHTTP = "SMQ_USERS_HTTP_" + envPrefixGRPC = "SMQ_USERS_GRPC_" envPrefixAuth = "SMQ_AUTH_GRPC_" envPrefixDomains = "SMQ_DOMAINS_GRPC_" envPrefixGoogle = "SMQ_GOOGLE_" defDB = "users" defSvcHTTPPort = "9002" + defSvcGRPCPort = "7002" ) type config struct { @@ -89,6 +95,7 @@ type config struct { SpicedbPreSharedKey string `env:"SMQ_SPICEDB_PRE_SHARED_KEY" envDefault:"12345678"` PasswordResetURLPrefix string `env:"SMQ_PASSWORD_RESET_URL_PREFIX" envDefault:"http://localhost/password/reset"` PasswordResetEmailTemplate string `env:"SMQ_PASSWORD_RESET_EMAIL_TEMPLATE" envDefault:"reset-password-email.tmpl"` + EmailTemplate string `env:"SM_EMAIL_TEMPLATE" envDefault:"email.tmpl"` VerificationURLPrefix string `env:"SMQ_VERIFICATION_URL_PREFIX" envDefault:"http://localhost/verify-email"` VerificationEmailTemplate string `env:"SMQ_VERIFICATION_EMAIL_TEMPLATE" envDefault:"verification-email.tmpl"` PassRegex *regexp.Regexp @@ -140,6 +147,14 @@ func main() { } verificationEmailConfig.Template = cfg.VerificationEmailTemplate + customEmailConfig := email.Config{} + if err := env.Parse(&customEmailConfig); err != nil { + logger.Error(fmt.Sprintf("failed to load custom email configuration : %s", err.Error())) + exitCode = 1 + return + } + customEmailConfig.Template = cfg.EmailTemplate + dbConfig := pgclient.Config{Name: defDB} if err := env.ParseWithOptions(&dbConfig, env.Options{Prefix: envPrefixDB}); err != nil { logger.Error(err.Error()) @@ -227,13 +242,26 @@ func main() { } logger.Info("Policy client successfully connected to spicedb gRPC server") - csvc, err := newService(ctx, authz, tokenClient, policyService, domainsClient, db, dbConfig, tracer, cfg, resetPasswordEmailConfig, verificationEmailConfig, logger) + csvc, err := newService(ctx, authz, tokenClient, policyService, domainsClient, db, dbConfig, tracer, cfg, resetPasswordEmailConfig, verificationEmailConfig, customEmailConfig, logger) if err != nil { logger.Error(fmt.Sprintf("failed to setup service: %s", err)) exitCode = 1 return } + grpcServerConfig := server.Config{Port: defSvcGRPCPort} + if err := env.ParseWithOptions(&grpcServerConfig, env.Options{Prefix: envPrefixGRPC}); err != nil { + logger.Error(fmt.Sprintf("failed to load %s gRPC server configuration : %s", svcName, err.Error())) + exitCode = 1 + return + } + registerUsersServiceServer := func(srv *grpc.Server) { + reflection.Register(srv) + grpcEmailsV1.RegisterEmailServiceServer(srv, usersgrpcapi.NewUsersServer(csvc)) + } + + gs := grpcserver.NewServer(ctx, cancel, svcName, grpcServerConfig, registerUsersServiceServer, logger) + httpServerConfig := server.Config{Port: defSvcHTTPPort} if err := env.ParseWithOptions(&httpServerConfig, env.Options{Prefix: envPrefixHTTP}); err != nil { logger.Error(fmt.Sprintf("failed to load %s HTTP server configuration : %s", svcName, err.Error())) @@ -258,12 +286,16 @@ func main() { go chc.CallHome(ctx) } + g.Go(func() error { + return gs.Start() + }) + g.Go(func() error { return httpSrv.Start() }) g.Go(func() error { - return server.StopSignalHandler(ctx, cancel, logger, svcName, httpSrv) + return server.StopSignalHandler(ctx, cancel, logger, svcName, httpSrv, gs) }) if err := g.Wait(); err != nil { @@ -271,7 +303,7 @@ func main() { } } -func newService(ctx context.Context, authz smqauthz.Authorization, token grpcTokenV1.TokenServiceClient, policyService policies.Service, domainsClient grpcDomainsV1.DomainsServiceClient, db *sqlx.DB, dbConfig pgclient.Config, tracer trace.Tracer, c config, resetPasswordEmailConfig, verificationEmailConfig email.Config, logger *slog.Logger) (users.Service, error) { +func newService(ctx context.Context, authz smqauthz.Authorization, token grpcTokenV1.TokenServiceClient, policyService policies.Service, domainsClient grpcDomainsV1.DomainsServiceClient, db *sqlx.DB, dbConfig pgclient.Config, tracer trace.Tracer, c config, resetPasswordEmailConfig, verificationEmailConfig, customEmailConfig email.Config, logger *slog.Logger) (users.Service, error) { database := pg.NewDatabase(db, dbConfig, tracer) idp := uuid.New() hsr := hasher.New() @@ -284,6 +316,7 @@ func newService(ctx context.Context, authz smqauthz.Authorization, token grpcTok c.VerificationURLPrefix, &resetPasswordEmailConfig, &verificationEmailConfig, + &customEmailConfig, ) if err != nil { logger.Error(fmt.Sprintf("failed to configure e-mailing util: %s", err.Error())) diff --git a/docker/.env b/docker/.env index 747b0e0a5c..255b64dfb8 100644 --- a/docker/.env +++ b/docker/.env @@ -154,6 +154,13 @@ SMQ_AUTH_GRPC_CLIENT_CERT=${GRPC_MTLS:+./ssl/certs/auth-grpc-client.crt} SMQ_AUTH_GRPC_CLIENT_KEY=${GRPC_MTLS:+./ssl/certs/auth-grpc-client.key} SMQ_AUTH_GRPC_CLIENT_CA_CERTS=${GRPC_MTLS:+./ssl/certs/ca.crt} +#### Users gRPC Client Config +SMQ_USERS_GRPC_URL=users:7002 +SMQ_USERS_GRPC_TIMEOUT=300s +SMQ_USERS_GRPC_CLIENT_CERT=${GRPC_MTLS:+./ssl/certs/users-grpc-client.crt} +SMQ_USERS_GRPC_CLIENT_KEY=${GRPC_MTLS:+./ssl/certs/users-grpc-client.key} +SMQ_USERS_GRPC_CLIENT_CA_CERTS=${GRPC_MTLS:+./ssl/certs/ca.crt} + ### Domains SMQ_DOMAINS_LOG_LEVEL=debug SMQ_DOMAINS_HTTP_HOST=domains @@ -239,6 +246,12 @@ SMQ_USERS_HTTP_HOST=users SMQ_USERS_HTTP_PORT=9002 SMQ_USERS_HTTP_SERVER_CERT= SMQ_USERS_HTTP_SERVER_KEY= +SMQ_USERS_GRPC_HOST=users +SMQ_USERS_GRPC_PORT=7002 +SMQ_USERS_GRPC_SERVER_CERT= +SMQ_USERS_GRPC_SERVER_KEY= +SMQ_USERS_GRPC_SERVER_CA_CERTS= +SMQ_USERS_GRPC_CLIENT_CA_CERTS= SMQ_USERS_DB_HOST=users-db SMQ_USERS_DB_PORT=5432 SMQ_USERS_DB_USER=supermq @@ -264,6 +277,7 @@ SMQ_PASSWORD_RESET_URL_PREFIX=http://localhost/password-reset SMQ_PASSWORD_RESET_EMAIL_TEMPLATE=reset-password-email.tmpl SMQ_VERIFICATION_URL_PREFIX=http://localhost/verify-email SMQ_VERIFICATION_EMAIL_TEMPLATE=verification-email.tmpl +SMQ_EMAIL_TEMPLATE=invitation.tmpl #### Users Client Config SMQ_USERS_URL=users:9002 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 3a04f7c4f9..745a35c56f 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -258,6 +258,11 @@ services: SMQ_AUTH_GRPC_CLIENT_CERT: ${SMQ_AUTH_GRPC_CLIENT_CERT:+/auth-grpc-client.crt} SMQ_AUTH_GRPC_CLIENT_KEY: ${SMQ_AUTH_GRPC_CLIENT_KEY:+/auth-grpc-client.key} SMQ_AUTH_GRPC_SERVER_CA_CERTS: ${SMQ_AUTH_GRPC_SERVER_CA_CERTS:+/auth-grpc-server-ca.crt} + SMQ_USERS_GRPC_URL: ${SMQ_USERS_GRPC_URL} + SMQ_USERS_GRPC_TIMEOUT: ${SMQ_USERS_GRPC_TIMEOUT} + SMQ_USERS_GRPC_CLIENT_CERT: ${SMQ_USERS_GRPC_CLIENT_CERT:+/users-grpc-client.crt} + SMQ_USERS_GRPC_CLIENT_KEY: ${SMQ_USERS_GRPC_CLIENT_KEY:+/users-grpc-client.key} + SMQ_USERS_GRPC_SERVER_CA_CERTS: ${SMQ_USERS_GRPC_SERVER_CA_CERTS:+/users-grpc-server-ca.crt} SMQ_GROUPS_GRPC_URL: ${SMQ_GROUPS_GRPC_URL} SMQ_GROUPS_GRPC_TIMEOUT: ${SMQ_GROUPS_GRPC_TIMEOUT} SMQ_GROUPS_GRPC_CLIENT_CERT: ${SMQ_GROUPS_GRPC_CLIENT_CERT:+/groups-grpc-client.crt} @@ -830,6 +835,8 @@ services: - users-db - auth - nats + expose: + - ${SMQ_USERS_GRPC_PORT} restart: on-failure environment: SMQ_USERS_LOG_LEVEL: ${SMQ_USERS_LOG_LEVEL} @@ -846,6 +853,12 @@ services: SMQ_USERS_HTTP_PORT: ${SMQ_USERS_HTTP_PORT} SMQ_USERS_HTTP_SERVER_CERT: ${SMQ_USERS_HTTP_SERVER_CERT} SMQ_USERS_HTTP_SERVER_KEY: ${SMQ_USERS_HTTP_SERVER_KEY} + SMQ_USERS_GRPC_HOST: ${SMQ_USERS_GRPC_HOST} + SMQ_USERS_GRPC_PORT: ${SMQ_USERS_GRPC_PORT} + SMQ_USERS_GRPC_SERVER_CERT: ${SMQ_USERS_GRPC_SERVER_CERT:+/users-grpc-server.crt} + SMQ_USERS_GRPC_SERVER_KEY: ${SMQ_USERS_GRPC_SERVER_KEY:+/users-grpc-server.key} + SMQ_USERS_GRPC_SERVER_CA_CERTS: ${SMQ_USERS_GRPC_SERVER_CA_CERTS:+/users-grpc-server-ca.crt} + SMQ_USERS_GRPC_CLIENT_CA_CERTS: ${SMQ_USERS_GRPC_CLIENT_CA_CERTS:+/users-grpc-client-ca.crt} SMQ_USERS_DB_HOST: ${SMQ_USERS_DB_HOST} SMQ_USERS_DB_PORT: ${SMQ_USERS_DB_PORT} SMQ_USERS_DB_USER: ${SMQ_USERS_DB_USER} @@ -899,6 +912,7 @@ services: volumes: - ./templates/${SMQ_PASSWORD_RESET_EMAIL_TEMPLATE}:/${SMQ_PASSWORD_RESET_EMAIL_TEMPLATE} - ./templates/${SMQ_VERIFICATION_EMAIL_TEMPLATE}:/${SMQ_VERIFICATION_EMAIL_TEMPLATE} + - ./templates/${SMQ_EMAIL_TEMPLATE}:/${SMQ_EMAIL_TEMPLATE} # Auth gRPC client certificates - type: bind source: ${SMQ_AUTH_GRPC_CLIENT_CERT:-ssl/certs/dummy/client_cert} diff --git a/docker/templates/invitation.tmpl b/docker/templates/invitation.tmpl new file mode 100644 index 0000000000..efba594d21 --- /dev/null +++ b/docker/templates/invitation.tmpl @@ -0,0 +1,13 @@ +Dear {{.User}}, + +Welcome to {{.Host}}! You have been invited to join a workspace on our platform. To complete your registration, please click on the link below to log in to your account and accept the invitation: + +{{.Content}} + +The invitation will expire in 24 hours. If you did not create an account on {{.Host}}, please disregard this message. + +Thank you for joining {{.Host}}! + +Best regards, + +{{.Footer}} diff --git a/domains/service.go b/domains/service.go index 7614665ed5..593318f851 100644 --- a/domains/service.go +++ b/domains/service.go @@ -8,6 +8,7 @@ import ( "time" "github.com/absmach/supermq" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" "github.com/absmach/supermq/pkg/authn" "github.com/absmach/supermq/pkg/errors" repoerr "github.com/absmach/supermq/pkg/errors/repository" @@ -22,16 +23,17 @@ var ( ) type service struct { - repo Repository - cache Cache - policy policies.Service - idProvider supermq.IDProvider + repo Repository + cache Cache + policy policies.Service + idProvider supermq.IDProvider + usersClient grpcEmailsV1.EmailServiceClient roles.ProvisionManageService } var _ Service = (*service)(nil) -func New(repo Repository, cache Cache, policy policies.Service, idProvider supermq.IDProvider, sidProvider supermq.IDProvider, availableActions []roles.Action, builtInRoles map[roles.BuiltInRoleName][]roles.Action) (Service, error) { +func New(repo Repository, cache Cache, policy policies.Service, idProvider supermq.IDProvider, sidProvider supermq.IDProvider, availableActions []roles.Action, builtInRoles map[roles.BuiltInRoleName][]roles.Action, usersClient grpcEmailsV1.EmailServiceClient) (Service, error) { rpms, err := roles.NewProvisionManageService(policies.DomainType, repo, policy, sidProvider, availableActions, builtInRoles) if err != nil { return nil, err @@ -42,6 +44,7 @@ func New(repo Repository, cache Cache, policy policies.Service, idProvider super cache: cache, policy: policy, idProvider: idProvider, + usersClient: usersClient, ProvisionManageService: rpms, }, nil } @@ -205,6 +208,17 @@ func (svc *service) SendInvitation(ctx context.Context, session authn.Session, i if err := svc.repo.SaveInvitation(ctx, invitation); err != nil { return errors.Wrap(svcerr.ErrCreateEntity, err) } + + if _, err := svc.usersClient.SendEmail(ctx, &grpcEmailsV1.EmailReq{ + Tos: []string{invitation.InviteeUserID}, + ToType: grpcEmailsV1.ContactType_CONTACT_TYPE_ID, + Subject: "Invitation", + From: invitation.InvitedBy, + FromType: grpcEmailsV1.ContactType_CONTACT_TYPE_ID, + }); err != nil { + return err + } + return nil } diff --git a/domains/service_test.go b/domains/service_test.go index 5025030c48..fb41cfc0d9 100644 --- a/domains/service_test.go +++ b/domains/service_test.go @@ -21,6 +21,7 @@ import ( "github.com/absmach/supermq/pkg/roles" "github.com/absmach/supermq/pkg/sid" "github.com/absmach/supermq/pkg/uuid" + uMocks "github.com/absmach/supermq/users/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -82,9 +83,10 @@ var ( ) var ( - drepo *mocks.Repository - dcache *mocks.Cache - policy *policiesMocks.Service + drepo *mocks.Repository + dcache *mocks.Cache + policy *policiesMocks.Service + usersClient *uMocks.EmailServiceClient ) func newService() domains.Service { @@ -93,11 +95,12 @@ func newService() domains.Service { idProvider := uuid.NewMock() sidProvider := sid.NewMock() policy = new(policiesMocks.Service) + usersClient = &uMocks.EmailServiceClient{} availableActions := []roles.Action{} builtInRoles := map[roles.BuiltInRoleName][]roles.Action{ groups.BuiltInRoleAdmin: availableActions, } - ds, _ := domains.New(drepo, dcache, policy, idProvider, sidProvider, availableActions, builtInRoles) + ds, _ := domains.New(drepo, dcache, policy, idProvider, sidProvider, availableActions, builtInRoles, usersClient) return ds } @@ -699,12 +702,14 @@ func TestSendInvitation(t *testing.T) { repoCall1 := drepo.On("SaveInvitation", context.Background(), mock.Anything).Return(tc.createInvitationErr) repoCall2 := drepo.On("RetrieveInvitation", context.Background(), tc.req.InviteeUserID, tc.req.DomainID).Return(tc.retrieveInvRes, tc.retrieveInvErr) repoCall3 := drepo.On("UpdateRejection", context.Background(), mock.Anything).Return(tc.updateRejectionErr) + usersClientCall := usersClient.On("SendEmail", mock.Anything, mock.Anything).Return(nil, nil) err := svc.SendInvitation(context.Background(), tc.session, tc.req) assert.True(t, errors.Contains(err, tc.err)) repoCall.Unset() repoCall1.Unset() repoCall2.Unset() repoCall3.Unset() + usersClientCall.Unset() }) } } diff --git a/groups/api/grpc/endpoint_test.go b/groups/api/grpc/endpoint_test.go index 12aae12247..08b30eb68c 100644 --- a/groups/api/grpc/endpoint_test.go +++ b/groups/api/grpc/endpoint_test.go @@ -25,8 +25,6 @@ import ( "google.golang.org/grpc/credentials/insecure" ) -const port = 7004 - var ( validID = testsutil.GenerateUUID(&testing.T{}) valid = "valid" @@ -48,8 +46,8 @@ var ( } ) -func startGRPCServer(svc *prmocks.Service, port int) { - listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) +func startGRPCServer(svc *prmocks.Service) (*grpc.Server, int) { + listener, err := net.Listen("tcp", ":0") if err != nil { panic(fmt.Sprintf("failed to obtain port: %s", err)) } @@ -60,12 +58,15 @@ func startGRPCServer(svc *prmocks.Service, port int) { panic(fmt.Sprintf("failed to serve: %s", err)) } }() + p := listener.Addr().(*net.TCPAddr).Port + return server, p } func TestRetrieveEntityEndpoint(t *testing.T) { svc := new(prmocks.Service) - startGRPCServer(svc, port) - grpAddr := fmt.Sprintf("localhost:%d", port) + server, p := startGRPCServer(svc) + defer server.GracefulStop() + grpAddr := fmt.Sprintf("localhost:%d", p) conn, _ := grpc.NewClient(grpAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := grpcapi.NewClient(conn, time.Second) diff --git a/internal/proto/emails/v1/emails.proto b/internal/proto/emails/v1/emails.proto new file mode 100644 index 0000000000..9829b62690 --- /dev/null +++ b/internal/proto/emails/v1/emails.proto @@ -0,0 +1,35 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package emails.v1; +option go_package = "github.com/absmach/supermq/api/grpc/emails/v1"; + +// EmailService is a service that provides email-related functionalities +// for SuperMQ services. +service EmailService { + rpc SendEmail(EmailReq) returns (SendEmailRes) {} +} + +enum ContactType { + CONTACT_TYPE_UNSPECIFIED = 0; + CONTACT_TYPE_ID = 1; + CONTACT_TYPE_EMAIL = 2; +} + +message EmailReq { + string from = 1; // Sender + ContactType from_type = 2; // indicate sender id or email + repeated string tos = 3; // recipients + ContactType to_type = 4; // indicate recipient id or email + string subject = 5; // Email subject + string content = 6; // Email content/URL + optional string template = 10; // whole template for emailer + optional string template_file = 11; // template file for emailer + map options = 12; // map for template +} + +message SendEmailRes { + string error = 1; +} diff --git a/pkg/grpcclient/client.go b/pkg/grpcclient/client.go index 7eb843617c..f8964f7c47 100644 --- a/pkg/grpcclient/client.go +++ b/pkg/grpcclient/client.go @@ -9,6 +9,7 @@ import ( grpcChannelsV1 "github.com/absmach/supermq/api/grpc/channels/v1" grpcClientsV1 "github.com/absmach/supermq/api/grpc/clients/v1" grpcDomainsV1 "github.com/absmach/supermq/api/grpc/domains/v1" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" grpcGroupsV1 "github.com/absmach/supermq/api/grpc/groups/v1" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" tokengrpc "github.com/absmach/supermq/auth/api/grpc/token" @@ -16,6 +17,7 @@ import ( clientsauth "github.com/absmach/supermq/clients/api/grpc" domainsgrpc "github.com/absmach/supermq/domains/api/grpc" groupsgrpc "github.com/absmach/supermq/groups/api/grpc" + usersgrpc "github.com/absmach/supermq/users/api/grpc" grpchealth "google.golang.org/grpc/health/grpc_health_v1" ) @@ -97,3 +99,25 @@ func SetupGroupsClient(ctx context.Context, cfg Config) (grpcGroupsV1.GroupsServ return groupsgrpc.NewClient(client.Connection(), cfg.Timeout), client, nil } + +// SetupUsersClient loads users gRPC configuration and creates new users gRPC client. +// +// For example: +// +// usersClient, usersHandler, err := grpcclient.SetupUsersClient(ctx, grpcclient.Config{}). +func SetupUsersClient(ctx context.Context, cfg Config) (grpcEmailsV1.EmailServiceClient, Handler, error) { + client, err := NewHandler(cfg) + if err != nil { + return nil, nil, err + } + + health := grpchealth.NewHealthClient(client.Connection()) + resp, err := health.Check(ctx, &grpchealth.HealthCheckRequest{ + Service: "users", + }) + if err != nil || resp.GetStatus() != grpchealth.HealthCheckResponse_SERVING { + return nil, nil, ErrSvcNotServing + } + + return usersgrpc.NewUsersClient(client.Connection(), cfg.Timeout), client, nil +} diff --git a/tools/config/.mockery.yaml b/tools/config/.mockery.yaml index 6e8c57e0aa..ff43d8a895 100644 --- a/tools/config/.mockery.yaml +++ b/tools/config/.mockery.yaml @@ -52,6 +52,11 @@ packages: dir: "./pkg/sdk/mocks" structname: "SDK" filename: "sdk.go" + github.com/absmach/supermq/api/grpc/emails/v1: + interfaces: + EmailServiceClient: + config: + dir: "./users/mocks" github.com/absmach/supermq/auth: interfaces: Authz: diff --git a/users/api/grpc/client.go b/users/api/grpc/client.go new file mode 100644 index 0000000000..53ed45cc7f --- /dev/null +++ b/users/api/grpc/client.go @@ -0,0 +1,102 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package grpc + +import ( + "context" + "time" + + grpcUsersV1 "github.com/absmach/supermq/api/grpc/emails/v1" + grpcapi "github.com/absmach/supermq/auth/api/grpc" + "github.com/go-kit/kit/endpoint" + kitgrpc "github.com/go-kit/kit/transport/grpc" + "google.golang.org/grpc" +) + +const usersSvcName = "users.v1.UsersService" + +var _ grpcUsersV1.EmailServiceClient = (*usersGrpcClient)(nil) + +type usersGrpcClient struct { + sendEmail endpoint.Endpoint + timeout time.Duration +} + +// NewUsersClient returns new users gRPC client instance. +func NewUsersClient(conn *grpc.ClientConn, timeout time.Duration) grpcUsersV1.EmailServiceClient { + return &usersGrpcClient{ + sendEmail: kitgrpc.NewClient( + conn, + usersSvcName, + "SendEmail", + encodeSendEmailClientRequest, + decodeSendEmailClientResponse, + grpcUsersV1.SendEmailRes{}, + ).Endpoint(), + timeout: timeout, + } +} + +func (client usersGrpcClient) SendEmail(ctx context.Context, in *grpcUsersV1.EmailReq, opts ...grpc.CallOption) (*grpcUsersV1.SendEmailRes, error) { + ctx, cancel := context.WithTimeout(ctx, client.timeout) + defer cancel() + + options := in.GetOptions() + res, err := client.sendEmail(ctx, sendEmailClientReq{ + to: in.GetTos(), + from: in.GetFrom(), + subject: in.GetSubject(), + header: options["header"], + user: options["user"], + content: in.GetContent(), + footer: options["footer"], + }) + if err != nil { + return &grpcUsersV1.SendEmailRes{}, grpcapi.DecodeError(err) + } + + ser := res.(sendEmailClientRes) + errMsg := "" + if !ser.sent { + errMsg = "failed to send email" + } + return &grpcUsersV1.SendEmailRes{Error: errMsg}, nil +} + +func decodeSendEmailClientResponse(_ context.Context, grpcRes any) (any, error) { + res := grpcRes.(*grpcUsersV1.SendEmailRes) + sent := res.GetError() == "" + return sendEmailClientRes{sent: sent}, nil +} + +func encodeSendEmailClientRequest(_ context.Context, grpcReq any) (any, error) { + req := grpcReq.(sendEmailClientReq) + return &grpcUsersV1.EmailReq{ + Tos: req.to, + ToType: grpcUsersV1.ContactType_CONTACT_TYPE_ID, + From: req.from, + FromType: grpcUsersV1.ContactType_CONTACT_TYPE_ID, + Subject: req.subject, + Content: req.content, + Options: map[string]string{ + "header": req.header, + "user": req.user, + "footer": req.footer, + }, + }, nil +} + +type sendEmailClientReq struct { + to []string + from string + subject string + header string + user string + content string + footer string +} + +type sendEmailClientRes struct { + sent bool +} diff --git a/users/api/grpc/endpoint.go b/users/api/grpc/endpoint.go new file mode 100644 index 0000000000..7f73eb845c --- /dev/null +++ b/users/api/grpc/endpoint.go @@ -0,0 +1,78 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package grpc + +import ( + "bytes" + "context" + "text/template" + + grpcUsersV1 "github.com/absmach/supermq/api/grpc/emails/v1" + "github.com/absmach/supermq/pkg/errors" + "github.com/absmach/supermq/users" + "github.com/go-kit/kit/endpoint" +) + +func sendEmailEndpoint(svc users.Service) endpoint.Endpoint { + return func(ctx context.Context, request any) (any, error) { + req := request.(sendEmailReq) + if err := req.validate(); err != nil { + return sendEmailRes{ + err: err, + }, err + } + + if err := svc.SendEmail(ctx, req.to, req.toType, req.from, req.fromType, req.subject, req.header, req.user, req.content, req.footer); err != nil { + return sendEmailRes{ + err: err, + }, err + } + + return sendEmailRes{ + sent: true, + }, nil + } +} + +type sendEmailReq struct { + to []string + toType grpcUsersV1.ContactType + from string + fromType grpcUsersV1.ContactType + subject string + header string + user string + content string + footer string + Template string + templateFile string + Options map[string]string +} + +func (req sendEmailReq) validate() error { + if len(req.to) == 0 { + return errors.ErrMalformedEntity + } + if req.subject == "" { + return errors.ErrMalformedEntity + } + + if req.Template != "" { + t, err := template.New("body").Parse(req.Template) + if err != nil { + return err + } + buff := new(bytes.Buffer) + if err := t.Execute(buff, req.Options); err != nil { + return err + } + } + + return nil +} + +type sendEmailRes struct { + sent bool + err error +} diff --git a/users/api/grpc/server.go b/users/api/grpc/server.go new file mode 100644 index 0000000000..8859a60763 --- /dev/null +++ b/users/api/grpc/server.go @@ -0,0 +1,78 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package grpc + +import ( + "context" + + grpcUsersV1 "github.com/absmach/supermq/api/grpc/emails/v1" + grpcapi "github.com/absmach/supermq/auth/api/grpc" + "github.com/absmach/supermq/users" + kitgrpc "github.com/go-kit/kit/transport/grpc" +) + +var _ grpcUsersV1.EmailServiceServer = (*usersGrpcServer)(nil) + +type usersGrpcServer struct { + grpcUsersV1.UnimplementedEmailServiceServer + sendEmail kitgrpc.Handler +} + +// NewUsersServer creates a new users gRPC server. +func NewUsersServer(svc users.Service) grpcUsersV1.EmailServiceServer { + return &usersGrpcServer{ + sendEmail: kitgrpc.NewServer( + sendEmailEndpoint(svc), + decodeSendEmailRequest, + encodeSendEmailResponse, + ), + } +} + +func decodeSendEmailRequest(_ context.Context, grpcReq any) (any, error) { + req := grpcReq.(*grpcUsersV1.EmailReq) + opts := req.GetOptions() + + tmpl := "" + if req.Template != nil { + tmpl = *req.Template + } + + templateFile := "" + if req.TemplateFile != nil { + templateFile = *req.TemplateFile + } + + return sendEmailReq{ + to: req.GetTos(), + toType: req.GetToType(), + from: req.GetFrom(), + fromType: req.GetFromType(), + subject: req.GetSubject(), + header: opts["header"], + user: opts["user"], + content: req.GetContent(), + footer: opts["footer"], + Template: tmpl, + templateFile: templateFile, + Options: opts, + }, nil +} + +func encodeSendEmailResponse(_ context.Context, grpcRes any) (any, error) { + res := grpcRes.(sendEmailRes) + errMsg := "" + if !res.sent && res.err != nil { + errMsg = res.err.Error() + } + return &grpcUsersV1.SendEmailRes{Error: errMsg}, nil +} + +func (s *usersGrpcServer) SendEmail(ctx context.Context, req *grpcUsersV1.EmailReq) (*grpcUsersV1.SendEmailRes, error) { + _, res, err := s.sendEmail.ServeGRPC(ctx, req) + if err != nil { + return nil, grpcapi.EncodeError(err) + } + return res.(*grpcUsersV1.SendEmailRes), nil +} diff --git a/users/emailer.go b/users/emailer.go index 4b93df933a..7859236c3b 100644 --- a/users/emailer.go +++ b/users/emailer.go @@ -10,4 +10,10 @@ type Emailer interface { // SendVerification sends an email to the user with a verification token. SendVerification(To []string, user, verificationToken string) error + + // Send sends an email with custom parameters. + Send(To []string, from, subject, header, user, content, footer string) error + + // SendCustom sends an email with custom parameters using a custom email agent. + SendCustom(To []string, from, subject, header, user, content, footer string) error } diff --git a/users/emailer/emailer.go b/users/emailer/emailer.go index 6cf4bdfb30..4eef2f2df2 100644 --- a/users/emailer/emailer.go +++ b/users/emailer/emailer.go @@ -17,10 +17,11 @@ type emailer struct { verificationURL string resetAgent *email.Agent verifyAgent *email.Agent + customAgent *email.Agent } // New creates new emailer utility. -func New(resetURL, verificationURL string, resetConfig, verifyConfig *email.Config) (users.Emailer, error) { +func New(resetURL, verificationURL string, resetConfig, verifyConfig, customEmailConfig *email.Config) (users.Emailer, error) { resetAgent, err := email.New(resetConfig) if err != nil { return nil, err @@ -31,11 +32,17 @@ func New(resetURL, verificationURL string, resetConfig, verifyConfig *email.Conf return nil, err } + customAgent, err := email.New(customEmailConfig) + if err != nil { + return nil, err + } + return &emailer{ resetURL: resetURL, verificationURL: verificationURL, resetAgent: resetAgent, verifyAgent: verifyAgent, + customAgent: customAgent, }, nil } @@ -48,3 +55,13 @@ func (e *emailer) SendVerification(to []string, user, verificationToken string) url := fmt.Sprintf("%s?token=%s", e.verificationURL, verificationToken) return e.verifyAgent.Send(to, "", "Email Verification", "", user, url, "") } + +func (e *emailer) Send(to []string, from, subject, header, user, content, footer string) error { + // Use the reset agent as the default agent for custom emails + return e.resetAgent.Send(to, from, subject, header, user, content, footer) +} + +func (e *emailer) SendCustom(to []string, from, subject, header, user, content, footer string) error { + // Use the custom agent for custom emails + return e.customAgent.Send(to, from, subject, header, user, content, footer) +} diff --git a/users/events/streams.go b/users/events/streams.go index ad3c5e376b..80350d03f2 100644 --- a/users/events/streams.go +++ b/users/events/streams.go @@ -6,6 +6,7 @@ package events import ( "context" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" "github.com/absmach/supermq/pkg/authn" "github.com/absmach/supermq/pkg/events" @@ -444,3 +445,7 @@ func (es *eventStore) OAuthAddUserPolicy(ctx context.Context, user users.User) e return es.Publish(ctx, addPolicyStream, event) } + +func (es *eventStore) SendEmail(ctx context.Context, to []string, toType grpcEmailsV1.ContactType, from string, fromType grpcEmailsV1.ContactType, subject, header, user, content, footer string) error { + return es.svc.SendEmail(ctx, to, toType, from, fromType, subject, header, user, content, footer) +} diff --git a/users/middleware/authorization.go b/users/middleware/authorization.go index 14b6a5d79f..33a24f9dd1 100644 --- a/users/middleware/authorization.go +++ b/users/middleware/authorization.go @@ -6,6 +6,7 @@ package middleware import ( "context" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" smqauth "github.com/absmach/supermq/auth" "github.com/absmach/supermq/pkg/authn" @@ -335,6 +336,10 @@ func (am *authorizationMiddleware) OAuthAddUserPolicy(ctx context.Context, user return am.svc.OAuthAddUserPolicy(ctx, user) } +func (am *authorizationMiddleware) SendEmail(ctx context.Context, to []string, toType grpcEmailsV1.ContactType, from string, fromType grpcEmailsV1.ContactType, subject, header, user, content, footer string) error { + return am.svc.SendEmail(ctx, to, toType, from, fromType, subject, header, user, content, footer) +} + func (am *authorizationMiddleware) checkSuperAdmin(ctx context.Context, session authn.Session) error { if session.Role != authn.AdminRole { return svcerr.ErrSuperAdminAction diff --git a/users/middleware/logging.go b/users/middleware/logging.go index 8339cfe9ca..7d37af5b5b 100644 --- a/users/middleware/logging.go +++ b/users/middleware/logging.go @@ -8,6 +8,7 @@ import ( "log/slog" "time" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" "github.com/absmach/supermq/pkg/authn" "github.com/absmach/supermq/users" @@ -521,3 +522,25 @@ func (lm *loggingMiddleware) OAuthAddUserPolicy(ctx context.Context, user users. }(time.Now()) return lm.svc.OAuthAddUserPolicy(ctx, user) } + +// SendEmail logs the send_email request. It logs the recipients and the time it took to complete the request. +func (lm *loggingMiddleware) SendEmail(ctx context.Context, to []string, toType grpcEmailsV1.ContactType, from string, fromType grpcEmailsV1.ContactType, subject, header, user, content, footer string) (err error) { + defer func(begin time.Time) { + args := []any{ + slog.String("duration", time.Since(begin).String()), + slog.String("request_id", middleware.GetReqID(ctx)), + slog.Any("to", to), + slog.String("to_type", toType.String()), + slog.String("from", from), + slog.String("from_type", fromType.String()), + slog.String("subject", subject), + } + if err != nil { + args = append(args, slog.String("error", err.Error())) + lm.logger.Warn("Send email failed", args...) + return + } + lm.logger.Info("Send email completed successfully", args...) + }(time.Now()) + return lm.svc.SendEmail(ctx, to, toType, from, fromType, subject, header, user, content, footer) +} diff --git a/users/middleware/metrics.go b/users/middleware/metrics.go index 1095e267d0..91438fd5f6 100644 --- a/users/middleware/metrics.go +++ b/users/middleware/metrics.go @@ -7,6 +7,7 @@ import ( "context" "time" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" "github.com/absmach/supermq/pkg/authn" "github.com/absmach/supermq/users" @@ -245,3 +246,12 @@ func (ms *metricsMiddleware) OAuthAddUserPolicy(ctx context.Context, user users. }(time.Now()) return ms.svc.OAuthAddUserPolicy(ctx, user) } + +// SendEmail instruments SendEmail method with metrics. +func (ms *metricsMiddleware) SendEmail(ctx context.Context, to []string, toType grpcEmailsV1.ContactType, from string, fromType grpcEmailsV1.ContactType, subject, header, user, content, footer string) error { + defer func(begin time.Time) { + ms.counter.With("method", "send_email").Add(1) + ms.latency.With("method", "send_email").Observe(time.Since(begin).Seconds()) + }(time.Now()) + return ms.svc.SendEmail(ctx, to, toType, from, fromType, subject, header, user, content, footer) +} diff --git a/users/middleware/tracing.go b/users/middleware/tracing.go index 9ee39763e7..bab9bebe26 100644 --- a/users/middleware/tracing.go +++ b/users/middleware/tracing.go @@ -6,6 +6,7 @@ package middleware import ( "context" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" "github.com/absmach/supermq/pkg/authn" "github.com/absmach/supermq/pkg/tracing" @@ -248,3 +249,17 @@ func (tm *tracingMiddleware) OAuthAddUserPolicy(ctx context.Context, user users. return tm.svc.OAuthAddUserPolicy(ctx, user) } + +// SendEmail traces the "SendEmail" operation of the wrapped users.Service. +func (tm *tracingMiddleware) SendEmail(ctx context.Context, to []string, toType grpcEmailsV1.ContactType, from string, fromType grpcEmailsV1.ContactType, subject, header, user, content, footer string) error { + ctx, span := tracing.StartSpan(ctx, tm.tracer, "svc_send_email", trace.WithAttributes( + attribute.StringSlice("to", to), + attribute.String("to_type", toType.String()), + attribute.String("from", from), + attribute.String("from_type", fromType.String()), + attribute.String("subject", subject), + )) + defer span.End() + + return tm.svc.SendEmail(ctx, to, toType, from, fromType, subject, header, user, content, footer) +} diff --git a/users/mocks/email_service_client.go b/users/mocks/email_service_client.go new file mode 100644 index 0000000000..d582e18840 --- /dev/null +++ b/users/mocks/email_service_client.go @@ -0,0 +1,127 @@ +// Copyright (c) Abstract Machines + +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/absmach/supermq/api/grpc/emails/v1" + mock "github.com/stretchr/testify/mock" + "google.golang.org/grpc" +) + +// NewEmailServiceClient creates a new instance of EmailServiceClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewEmailServiceClient(t interface { + mock.TestingT + Cleanup(func()) +}) *EmailServiceClient { + mock := &EmailServiceClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// EmailServiceClient is an autogenerated mock type for the EmailServiceClient type +type EmailServiceClient struct { + mock.Mock +} + +type EmailServiceClient_Expecter struct { + mock *mock.Mock +} + +func (_m *EmailServiceClient) EXPECT() *EmailServiceClient_Expecter { + return &EmailServiceClient_Expecter{mock: &_m.Mock} +} + +// SendEmail provides a mock function for the type EmailServiceClient +func (_mock *EmailServiceClient) SendEmail(ctx context.Context, in *v1.EmailReq, opts ...grpc.CallOption) (*v1.SendEmailRes, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, in, opts) + } else { + tmpRet = _mock.Called(ctx, in) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for SendEmail") + } + + var r0 *v1.SendEmailRes + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *v1.EmailReq, ...grpc.CallOption) (*v1.SendEmailRes, error)); ok { + return returnFunc(ctx, in, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *v1.EmailReq, ...grpc.CallOption) *v1.SendEmailRes); ok { + r0 = returnFunc(ctx, in, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1.SendEmailRes) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *v1.EmailReq, ...grpc.CallOption) error); ok { + r1 = returnFunc(ctx, in, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// EmailServiceClient_SendEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendEmail' +type EmailServiceClient_SendEmail_Call struct { + *mock.Call +} + +// SendEmail is a helper method to define mock.On call +// - ctx context.Context +// - in *v1.EmailReq +// - opts ...grpc.CallOption +func (_e *EmailServiceClient_Expecter) SendEmail(ctx interface{}, in interface{}, opts ...interface{}) *EmailServiceClient_SendEmail_Call { + return &EmailServiceClient_SendEmail_Call{Call: _e.mock.On("SendEmail", + append([]interface{}{ctx, in}, opts...)...)} +} + +func (_c *EmailServiceClient_SendEmail_Call) Run(run func(ctx context.Context, in *v1.EmailReq, opts ...grpc.CallOption)) *EmailServiceClient_SendEmail_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *v1.EmailReq + if args[1] != nil { + arg1 = args[1].(*v1.EmailReq) + } + var arg2 []grpc.CallOption + var variadicArgs []grpc.CallOption + if len(args) > 2 { + variadicArgs = args[2].([]grpc.CallOption) + } + arg2 = variadicArgs + run( + arg0, + arg1, + arg2..., + ) + }) + return _c +} + +func (_c *EmailServiceClient_SendEmail_Call) Return(sendEmailRes *v1.SendEmailRes, err error) *EmailServiceClient_SendEmail_Call { + _c.Call.Return(sendEmailRes, err) + return _c +} + +func (_c *EmailServiceClient_SendEmail_Call) RunAndReturn(run func(ctx context.Context, in *v1.EmailReq, opts ...grpc.CallOption) (*v1.SendEmailRes, error)) *EmailServiceClient_SendEmail_Call { + _c.Call.Return(run) + return _c +} diff --git a/users/mocks/emailer.go b/users/mocks/emailer.go index 5ebeea6845..cdabbb1640 100644 --- a/users/mocks/emailer.go +++ b/users/mocks/emailer.go @@ -39,6 +39,180 @@ func (_m *Emailer) EXPECT() *Emailer_Expecter { return &Emailer_Expecter{mock: &_m.Mock} } +// Send provides a mock function for the type Emailer +func (_mock *Emailer) Send(To []string, from string, subject string, header string, user string, content string, footer string) error { + ret := _mock.Called(To, from, subject, header, user, content, footer) + + if len(ret) == 0 { + panic("no return value specified for Send") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func([]string, string, string, string, string, string, string) error); ok { + r0 = returnFunc(To, from, subject, header, user, content, footer) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Emailer_Send_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Send' +type Emailer_Send_Call struct { + *mock.Call +} + +// Send is a helper method to define mock.On call +// - To []string +// - from string +// - subject string +// - header string +// - user string +// - content string +// - footer string +func (_e *Emailer_Expecter) Send(To interface{}, from interface{}, subject interface{}, header interface{}, user interface{}, content interface{}, footer interface{}) *Emailer_Send_Call { + return &Emailer_Send_Call{Call: _e.mock.On("Send", To, from, subject, header, user, content, footer)} +} + +func (_c *Emailer_Send_Call) Run(run func(To []string, from string, subject string, header string, user string, content string, footer string)) *Emailer_Send_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 []string + if args[0] != nil { + arg0 = args[0].([]string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } + var arg5 string + if args[5] != nil { + arg5 = args[5].(string) + } + var arg6 string + if args[6] != nil { + arg6 = args[6].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + arg5, + arg6, + ) + }) + return _c +} + +func (_c *Emailer_Send_Call) Return(err error) *Emailer_Send_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Emailer_Send_Call) RunAndReturn(run func(To []string, from string, subject string, header string, user string, content string, footer string) error) *Emailer_Send_Call { + _c.Call.Return(run) + return _c +} + +// SendCustom provides a mock function for the type Emailer +func (_mock *Emailer) SendCustom(To []string, from string, subject string, header string, user string, content string, footer string) error { + ret := _mock.Called(To, from, subject, header, user, content, footer) + + if len(ret) == 0 { + panic("no return value specified for SendCustom") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func([]string, string, string, string, string, string, string) error); ok { + r0 = returnFunc(To, from, subject, header, user, content, footer) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Emailer_SendCustom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendCustom' +type Emailer_SendCustom_Call struct { + *mock.Call +} + +// SendCustom is a helper method to define mock.On call +// - To []string +// - from string +// - subject string +// - header string +// - user string +// - content string +// - footer string +func (_e *Emailer_Expecter) SendCustom(To interface{}, from interface{}, subject interface{}, header interface{}, user interface{}, content interface{}, footer interface{}) *Emailer_SendCustom_Call { + return &Emailer_SendCustom_Call{Call: _e.mock.On("SendCustom", To, from, subject, header, user, content, footer)} +} + +func (_c *Emailer_SendCustom_Call) Run(run func(To []string, from string, subject string, header string, user string, content string, footer string)) *Emailer_SendCustom_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 []string + if args[0] != nil { + arg0 = args[0].([]string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } + var arg5 string + if args[5] != nil { + arg5 = args[5].(string) + } + var arg6 string + if args[6] != nil { + arg6 = args[6].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + arg5, + arg6, + ) + }) + return _c +} + +func (_c *Emailer_SendCustom_Call) Return(err error) *Emailer_SendCustom_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Emailer_SendCustom_Call) RunAndReturn(run func(To []string, from string, subject string, header string, user string, content string, footer string) error) *Emailer_SendCustom_Call { + _c.Call.Return(run) + return _c +} + // SendPasswordReset provides a mock function for the type Emailer func (_mock *Emailer) SendPasswordReset(To []string, user string, token string) error { ret := _mock.Called(To, user, token) diff --git a/users/mocks/service.go b/users/mocks/service.go index 74a090668b..f92329dd25 100644 --- a/users/mocks/service.go +++ b/users/mocks/service.go @@ -11,6 +11,7 @@ package mocks import ( "context" + v10 "github.com/absmach/supermq/api/grpc/emails/v1" "github.com/absmach/supermq/api/grpc/token/v1" "github.com/absmach/supermq/pkg/authn" "github.com/absmach/supermq/users" @@ -867,6 +868,111 @@ func (_c *Service_SearchUsers_Call) RunAndReturn(run func(ctx context.Context, p return _c } +// SendEmail provides a mock function for the type Service +func (_mock *Service) SendEmail(ctx context.Context, to []string, toType v10.ContactType, from string, fromType v10.ContactType, subject string, header string, user string, content string, footer string) error { + ret := _mock.Called(ctx, to, toType, from, fromType, subject, header, user, content, footer) + + if len(ret) == 0 { + panic("no return value specified for SendEmail") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []string, v10.ContactType, string, v10.ContactType, string, string, string, string, string) error); ok { + r0 = returnFunc(ctx, to, toType, from, fromType, subject, header, user, content, footer) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Service_SendEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendEmail' +type Service_SendEmail_Call struct { + *mock.Call +} + +// SendEmail is a helper method to define mock.On call +// - ctx context.Context +// - to []string +// - toType v10.ContactType +// - from string +// - fromType v10.ContactType +// - subject string +// - header string +// - user string +// - content string +// - footer string +func (_e *Service_Expecter) SendEmail(ctx interface{}, to interface{}, toType interface{}, from interface{}, fromType interface{}, subject interface{}, header interface{}, user interface{}, content interface{}, footer interface{}) *Service_SendEmail_Call { + return &Service_SendEmail_Call{Call: _e.mock.On("SendEmail", ctx, to, toType, from, fromType, subject, header, user, content, footer)} +} + +func (_c *Service_SendEmail_Call) Run(run func(ctx context.Context, to []string, toType v10.ContactType, from string, fromType v10.ContactType, subject string, header string, user string, content string, footer string)) *Service_SendEmail_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []string + if args[1] != nil { + arg1 = args[1].([]string) + } + var arg2 v10.ContactType + if args[2] != nil { + arg2 = args[2].(v10.ContactType) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 v10.ContactType + if args[4] != nil { + arg4 = args[4].(v10.ContactType) + } + var arg5 string + if args[5] != nil { + arg5 = args[5].(string) + } + var arg6 string + if args[6] != nil { + arg6 = args[6].(string) + } + var arg7 string + if args[7] != nil { + arg7 = args[7].(string) + } + var arg8 string + if args[8] != nil { + arg8 = args[8].(string) + } + var arg9 string + if args[9] != nil { + arg9 = args[9].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + arg5, + arg6, + arg7, + arg8, + arg9, + ) + }) + return _c +} + +func (_c *Service_SendEmail_Call) Return(err error) *Service_SendEmail_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Service_SendEmail_Call) RunAndReturn(run func(ctx context.Context, to []string, toType v10.ContactType, from string, fromType v10.ContactType, subject string, header string, user string, content string, footer string) error) *Service_SendEmail_Call { + _c.Call.Return(run) + return _c +} + // SendPasswordReset provides a mock function for the type Service func (_mock *Service) SendPasswordReset(ctx context.Context, email string) error { ret := _mock.Called(ctx, email) diff --git a/users/service.go b/users/service.go index a5cb975733..f707e1e121 100644 --- a/users/service.go +++ b/users/service.go @@ -15,6 +15,7 @@ import ( "time" "github.com/absmach/supermq" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" apiutil "github.com/absmach/supermq/api/http/util" smqauth "github.com/absmach/supermq/auth" @@ -814,3 +815,39 @@ func changed(updated *string, old string) bool { return *updated != old } + +func (svc service) SendEmail(ctx context.Context, to []string, toType grpcEmailsV1.ContactType, from string, fromType grpcEmailsV1.ContactType, subject, header, user, content, footer string) error { + // Convert recipients based on contact type + emails := make([]string, len(to)) + for i, contact := range to { + switch toType { + case grpcEmailsV1.ContactType_CONTACT_TYPE_ID: + u, err := svc.users.RetrieveByID(ctx, contact) + if err != nil { + return errors.Wrap(svcerr.ErrViewEntity, err) + } + emails[i] = u.Email + case grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL: + emails[i] = contact + default: + return errors.Wrap(svcerr.ErrMalformedEntity, errors.New("invalid contact type for recipients")) + } + } + + // Convert sender based on contact type + var senderName string + switch fromType { + case grpcEmailsV1.ContactType_CONTACT_TYPE_ID: + inviter, err := svc.users.RetrieveByID(ctx, from) + if err != nil { + return errors.Wrap(svcerr.ErrViewEntity, err) + } + senderName = inviter.FirstName + " " + inviter.LastName + case grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL: + senderName = from + default: + return errors.Wrap(svcerr.ErrMalformedEntity, errors.New("invalid contact type for sender")) + } + + return svc.email.SendCustom(emails, senderName, subject, header, user, content, footer) +} diff --git a/users/service_test.go b/users/service_test.go index 2dfd230e72..89989d4494 100644 --- a/users/service_test.go +++ b/users/service_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" smqauth "github.com/absmach/supermq/auth" authmocks "github.com/absmach/supermq/auth/mocks" @@ -2066,3 +2067,190 @@ func TestVerifyEmail(t *testing.T) { }) } } + +func TestSendEmail(t *testing.T) { + svc, _, cRepo, _, e := newService() + + recipientID := "d4ebb847-5d0e-4e46-bdd9-b6aceaaa3a22" + senderID := "e5fcc958-6e1f-5f57-cee0-c7bdfbbb4a33" + recipientEmail := "recipient@example.com" + senderEmail := "sender@example.com" + subject := "Test Subject" + header := "Test Header" + userField := "Test User" + content := "Test Content" + footer := "Test Footer" + + recipientUser := users.User{ + ID: recipientID, + Email: recipientEmail, + FirstName: "Recipient", + LastName: "User", + } + + senderUser := users.User{ + ID: senderID, + Email: senderEmail, + FirstName: "Sender", + LastName: "User", + } + + cases := []struct { + desc string + to []string + toType grpcEmailsV1.ContactType + from string + fromType grpcEmailsV1.ContactType + retrieveRecipientID string + retrieveRecipient users.User + retrieveRecipientErr error + retrieveSenderID string + retrieveSender users.User + retrieveSenderErr error + expectedEmails []string + expectedSenderName string + sendCustomErr error + err error + }{ + { + desc: "send email with ID contact types successfully", + to: []string{recipientID}, + toType: grpcEmailsV1.ContactType_CONTACT_TYPE_ID, + from: senderID, + fromType: grpcEmailsV1.ContactType_CONTACT_TYPE_ID, + retrieveRecipientID: recipientID, + retrieveRecipient: recipientUser, + retrieveSenderID: senderID, + retrieveSender: senderUser, + expectedEmails: []string{recipientEmail}, + expectedSenderName: "Sender User", + err: nil, + }, + { + desc: "send email with EMAIL contact types successfully", + to: []string{recipientEmail}, + toType: grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL, + from: senderEmail, + fromType: grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL, + expectedEmails: []string{recipientEmail}, + expectedSenderName: senderEmail, + err: nil, + }, + { + desc: "send email with mixed contact types (to: ID, from: EMAIL)", + to: []string{recipientID}, + toType: grpcEmailsV1.ContactType_CONTACT_TYPE_ID, + from: senderEmail, + fromType: grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL, + retrieveRecipientID: recipientID, + retrieveRecipient: recipientUser, + expectedEmails: []string{recipientEmail}, + expectedSenderName: senderEmail, + err: nil, + }, + { + desc: "send email with mixed contact types (to: EMAIL, from: ID)", + to: []string{recipientEmail}, + toType: grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL, + from: senderID, + fromType: grpcEmailsV1.ContactType_CONTACT_TYPE_ID, + retrieveSenderID: senderID, + retrieveSender: senderUser, + expectedEmails: []string{recipientEmail}, + expectedSenderName: "Sender User", + err: nil, + }, + { + desc: "send email fails when recipient ID not found", + to: []string{recipientID}, + toType: grpcEmailsV1.ContactType_CONTACT_TYPE_ID, + from: senderEmail, + fromType: grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL, + retrieveRecipientID: recipientID, + retrieveRecipientErr: repoerr.ErrNotFound, + err: svcerr.ErrViewEntity, + }, + { + desc: "send email fails when sender ID not found", + to: []string{recipientEmail}, + toType: grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL, + from: senderID, + fromType: grpcEmailsV1.ContactType_CONTACT_TYPE_ID, + retrieveSenderID: senderID, + retrieveSenderErr: repoerr.ErrNotFound, + err: svcerr.ErrViewEntity, + }, + { + desc: "send email fails with invalid recipient contact type", + to: []string{recipientID}, + toType: grpcEmailsV1.ContactType_CONTACT_TYPE_UNSPECIFIED, + from: senderEmail, + fromType: grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL, + expectedEmails: []string{}, + expectedSenderName: "", + err: svcerr.ErrMalformedEntity, + }, + { + desc: "send email fails with invalid sender contact type", + to: []string{recipientEmail}, + toType: grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL, + from: senderID, + fromType: grpcEmailsV1.ContactType_CONTACT_TYPE_UNSPECIFIED, + err: svcerr.ErrMalformedEntity, + }, + { + desc: "send email with multiple recipients using IDs", + to: []string{recipientID, recipientID}, + toType: grpcEmailsV1.ContactType_CONTACT_TYPE_ID, + from: senderEmail, + fromType: grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL, + retrieveRecipientID: recipientID, + retrieveRecipient: recipientUser, + expectedEmails: []string{recipientEmail, recipientEmail}, + expectedSenderName: senderEmail, + err: nil, + }, + { + desc: "send email fails when email sending fails", + to: []string{recipientEmail}, + toType: grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL, + from: senderEmail, + fromType: grpcEmailsV1.ContactType_CONTACT_TYPE_EMAIL, + expectedEmails: []string{recipientEmail}, + expectedSenderName: senderEmail, + sendCustomErr: errors.New("email sending failed"), + err: errors.New("email sending failed"), + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + var repoCall, repoCall1 *mock.Call + + if tc.retrieveRecipientID != "" { + repoCall = cRepo.On("RetrieveByID", context.Background(), tc.retrieveRecipientID).Return(tc.retrieveRecipient, tc.retrieveRecipientErr) + } + if tc.retrieveSenderID != "" { + repoCall1 = cRepo.On("RetrieveByID", context.Background(), tc.retrieveSenderID).Return(tc.retrieveSender, tc.retrieveSenderErr) + } + + if tc.err == nil || (tc.retrieveRecipientErr == nil && tc.retrieveSenderErr == nil) { + if len(tc.expectedEmails) > 0 { + e.On("SendCustom", tc.expectedEmails, tc.expectedSenderName, subject, header, userField, content, footer).Return(tc.sendCustomErr) + } + } + + err := svc.SendEmail(context.Background(), tc.to, tc.toType, tc.from, tc.fromType, subject, header, userField, content, footer) + + assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err)) + + if repoCall != nil { + repoCall.Unset() + } + if repoCall1 != nil { + repoCall1.Unset() + } + e.Mock = mock.Mock{} + }) + } +} diff --git a/users/users.go b/users/users.go index 4a269f4215..07591408d6 100644 --- a/users/users.go +++ b/users/users.go @@ -8,6 +8,7 @@ import ( "net/mail" "time" + grpcEmailsV1 "github.com/absmach/supermq/api/grpc/emails/v1" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" "github.com/absmach/supermq/pkg/authn" "github.com/absmach/supermq/pkg/errors" @@ -243,4 +244,8 @@ type Service interface { // OAuthAddUserPolicy adds a policy to the user for an OAuth request. OAuthAddUserPolicy(ctx context.Context, user User) error + + // SendEmail sends an email using the email agent. + // fromType and toType indicate whether from and to are IDs or emails (ContactType enum). + SendEmail(ctx context.Context, to []string, toType grpcEmailsV1.ContactType, from string, fromType grpcEmailsV1.ContactType, subject, header, user, content, footer string) error }