diff --git a/packages/api-gateway/src/models/email.dto.ts b/packages/api-gateway/src/models/email.dto.ts index 7cf0e7c9..38acdcb3 100644 --- a/packages/api-gateway/src/models/email.dto.ts +++ b/packages/api-gateway/src/models/email.dto.ts @@ -536,6 +536,102 @@ export class SendGridDNSResponse { } } +export class VerifiedSenderResponse { + @ApiProperty({ type: 'number' }) + id: number; + + @ApiProperty({ type: 'string' }) + nickname: string; + + @ApiProperty({ type: 'string' }) + fromEmail: string; + + @ApiProperty({ type: 'string' }) + fromName: string; + + @ApiProperty({ type: 'string' }) + replyTo: string; + + @ApiProperty({ type: 'string' }) + address: string; + + @ApiProperty({ type: 'string' }) + city: string; + + @ApiProperty({ type: 'string' }) + state: string; + + @ApiProperty({ type: 'string' }) + country: string; + + @ApiProperty({ type: 'string' }) + zip: string; + + @ApiProperty({ type: 'boolean' }) + verified: boolean; + + @ApiProperty({ type: 'boolean' }) + locked: boolean; + + constructor(sender: EmailProto.VerifiedSender) { + this.id = Number(sender.id); + this.nickname = sender.nickname; + this.fromEmail = sender.fromEmail; + this.fromName = sender.fromName; + this.replyTo = sender.replyTo; + this.address = sender.address; + this.city = sender.city; + this.state = sender.state; + this.country = sender.country; + this.zip = sender.zip; + this.verified = sender.verified; + this.locked = sender.locked; + } +} + +export class GetSendersResponse { + @ApiProperty({ type: [VerifiedSenderResponse] }) + senders: VerifiedSenderResponse[]; + + constructor(res: EmailProto.GetSendersResponse) { + this.senders = (res.senders ?? []).map( + (s) => new VerifiedSenderResponse(s), + ); + } +} + +export class AuthenticatedDomainResponse { + @ApiProperty({ type: 'number' }) + id: number; + + @ApiProperty({ type: 'string' }) + domain: string; + + @ApiPropertyOptional({ type: 'string' }) + subdomain?: string; + + @ApiProperty({ type: 'boolean' }) + valid: boolean; + + constructor(domain: EmailProto.AuthenticatedDomain) { + this.id = Number(domain.id); + this.domain = domain.domain; + this.subdomain = domain.subdomain; + this.valid = domain.valid; + } +} + +export class GetDomainsResponse { + @ApiProperty({ type: [AuthenticatedDomainResponse] }) + domains: AuthenticatedDomainResponse[]; + + constructor(res: EmailProto.GetDomainsResponse) { + this.domains = (res.domains ?? []).map( + (d) => new AuthenticatedDomainResponse(d), + ); + } +} + export class RegisterDomainResponse { @ApiProperty({ type: 'number' }) id: number; diff --git a/packages/api-gateway/src/modules/email/email.controller.ts b/packages/api-gateway/src/modules/email/email.controller.ts index 938fa0c2..c62c26fb 100644 --- a/packages/api-gateway/src/modules/email/email.controller.ts +++ b/packages/api-gateway/src/modules/email/email.controller.ts @@ -28,6 +28,8 @@ import { VerifyDomainModel, AggregationInterval, SendEmailStatisticsResponses, + GetSendersResponse, + GetDomainsResponse, } from 'src/models/email.dto'; import { @@ -352,4 +354,50 @@ export class EmailController implements OnModuleInit { ), ); } + + @ApiOperation({ summary: 'Gets all verified senders from SendGrid' }) + @ApiOkResponse({ + description: 'Returned verified senders', + type: GetSendersResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @Get('/senders') + async getSenders( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @ProjectId() projectId: number, + ): Promise { + return new GetSendersResponse( + await lastValueFrom( + this.emailService.getSenders({ + configId: projectId, + configEnvironment: apiKey.environment, + }), + ), + ); + } + + @ApiOperation({ + summary: 'Gets all authenticated domains from SendGrid', + }) + @ApiOkResponse({ + description: 'Returned authenticated domains', + type: GetDomainsResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @Get('/domains') + async getDomains( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @ProjectId() projectId: number, + ): Promise { + return new GetDomainsResponse( + await lastValueFrom( + this.emailService.getDomains({ + configId: projectId, + configEnvironment: apiKey.environment, + }), + ), + ); + } } diff --git a/packages/api-gateway/test/email.e2e-spec.ts b/packages/api-gateway/test/email.e2e-spec.ts index b72442fd..5e21671d 100644 --- a/packages/api-gateway/test/email.e2e-spec.ts +++ b/packages/api-gateway/test/email.e2e-spec.ts @@ -617,6 +617,54 @@ describe('Domain Registration Routes', () => { }); }); +describe('Email Senders Routes', () => { + it('Successfully gets senders', async () => { + return request(app.getHttpServer()) + .get('/email/senders') + .set('Authorization', 'Bearer ' + apiKey) + .expect(200) + .then((response) => { + expect(response.body).toHaveProperty('senders'); + expect(Array.isArray(response.body.senders)).toBe(true); + }); + }); + + it('Fails without an Authorization header', async () => { + return request(app.getHttpServer()).get('/email/senders').expect(401); + }); + + it('Fails with an invalid API Key', async () => { + return request(app.getHttpServer()) + .get('/email/senders') + .set('Authorization', 'Bearer invalid.api.key') + .expect(401); + }); +}); + +describe('Email Domains Routes', () => { + it('Successfully gets domains', async () => { + return request(app.getHttpServer()) + .get('/email/domains') + .set('Authorization', 'Bearer ' + apiKey) + .expect(200) + .then((response) => { + expect(response.body).toHaveProperty('domains'); + expect(Array.isArray(response.body.domains)).toBe(true); + }); + }); + + it('Fails without an Authorization header', async () => { + return request(app.getHttpServer()).get('/email/domains').expect(401); + }); + + it('Fails with an invalid API Key', async () => { + return request(app.getHttpServer()) + .get('/email/domains') + .set('Authorization', 'Bearer invalid.api.key') + .expect(401); + }); +}); + describe('Domain Verification Routes', () => { it('Verifies a domain without a domain parameter', () => { return request(app.getHttpServer()) diff --git a/packages/db-service/src/modules/email/email.controller.ts b/packages/db-service/src/modules/email/email.controller.ts index 36338eb6..f270bf0e 100644 --- a/packages/db-service/src/modules/email/email.controller.ts +++ b/packages/db-service/src/modules/email/email.controller.ts @@ -230,7 +230,7 @@ export class EmailController implements EmailDbServiceController { const emailDomain = await this.emailService.createEmailDomain({ domain: request.domain, subdomain: request.subdomain, - sendgridId: request.sendgridId, + sendgridId: Number(request.sendgridId), attachedConfigs: { connectOrCreate: { create: { diff --git a/packages/email-service/src/modules/email/email.controller.ts b/packages/email-service/src/modules/email/email.controller.ts index 527409b5..890b2981 100644 --- a/packages/email-service/src/modules/email/email.controller.ts +++ b/packages/email-service/src/modules/email/email.controller.ts @@ -108,4 +108,16 @@ export class EmailController implements EmailProto.EmailServiceController { ): Promise { return await this.emailService.getStatistics(request); } + + async getSenders( + request: EmailProto.GetSendersRequest, + ): Promise { + return await this.emailService.getSenders(request); + } + + async getDomains( + request: EmailProto.GetDomainsRequest, + ): Promise { + return await this.emailService.getDomains(request); + } } diff --git a/packages/email-service/src/modules/email/email.service.ts b/packages/email-service/src/modules/email/email.service.ts index d66f27ee..d10fbd47 100644 --- a/packages/email-service/src/modules/email/email.service.ts +++ b/packages/email-service/src/modules/email/email.service.ts @@ -15,6 +15,9 @@ const { EMAIL_DB_SERVICE_NAME } = EmailProto; @Injectable() export class EmailService implements OnModuleInit { private emailService: EmailProto.EmailDbServiceClient; + private testDomains: Map = + new Map(); + private testSenders: Map = new Map(); constructor(@Inject(EMAIL_DB_SERVICE_NAME) private emailClient: ClientGrpc) {} onModuleInit() { @@ -71,13 +74,26 @@ export class EmailService implements OnModuleInit { const sendGridUrl = 'https://api.sendgrid.com/v3/whitelabel/domains'; if (process.env['NODE_ENV'] == 'test') { - this.emailService.createEmailDomain({ + const configKey = `${req.configId}:${req.configEnvironment}`; + const domains = this.testDomains.get(configKey) ?? []; + domains.push({ + id: 0, domain: req.domain, - subdomain: req.subdomain, - sendgridId: 0, - configId: req.configId, - configEnvironment: req.configEnvironment, + subdomain: req.subdomain ?? undefined, + valid: false, }); + this.testDomains.set(configKey, domains); + + lastValueFrom( + this.emailService.createEmailDomain({ + domain: req.domain, + subdomain: req.subdomain, + sendgridId: 0, + configId: req.configId, + configEnvironment: req.configEnvironment, + }), + ).catch(() => {}); + return { statusCode: 201, id: 0, @@ -107,15 +123,15 @@ export class EmailService implements OnModuleInit { mailCname: response.data.dns.mail_cname, }; - // await lastValueFrom( - // this.emailService.createEmailDomain({ - // domain: req.domain, - // subdomain: req.subdomain, - // sendgridId: response.data.id, - // configId: req.configId, - // configEnvironment: req.configEnvironment, - // }), - // ); + await lastValueFrom( + this.emailService.createEmailDomain({ + domain: req.domain, + subdomain: req.subdomain, + sendgridId: response.data.id, + configId: req.configId, + configEnvironment: req.configEnvironment, + }), + ); return { statusCode: response.status, @@ -211,6 +227,24 @@ export class EmailService implements OnModuleInit { } if (process.env['NODE_ENV'] == 'test') { + const configKey = `${req.configId}:${req.configEnvironment}`; + const senders = this.testSenders.get(configKey) ?? []; + senders.push({ + id: senders.length, + nickname: req.nickname ?? req.fromName, + fromEmail: req.fromEmail, + fromName: req.fromName, + replyTo: req.replyTo, + address: req.address, + city: req.city, + state: req.state, + country: req.country, + zip: req.zip, + verified: false, + locked: false, + }); + this.testSenders.set(configKey, senders); + return { statusCode: 201, message: 'test register success', @@ -312,6 +346,122 @@ export class EmailService implements OnModuleInit { }); } } + async getSenders( + req: EmailProto.GetSendersRequest, + ): Promise { + const config = await lastValueFrom( + this.emailService.getEmailServiceConfig({ + id: Number(req.configId), + environment: req.configEnvironment, + }), + ); + + const sendgridApiKey = config.sendgridKey; + + if (!sendgridApiKey) { + throw new RpcException({ + code: status.FAILED_PRECONDITION, + message: 'Cannot get senders (SendGrid API key is missing)', + }); + } + + if (process.env['NODE_ENV'] == 'test') { + const configKey = `${req.configId}:${req.configEnvironment}`; + return { senders: this.testSenders.get(configKey) ?? [] }; + } + + try { + const response = await axios.get( + 'https://api.sendgrid.com/v3/verified_senders', + { + headers: { + Authorization: `Bearer ${sendgridApiKey}`, + 'Content-Type': 'application/json', + }, + }, + ); + + const senders = (response.data.results ?? []).map( + (sender: SendGridVerifiedSender) => ({ + id: sender.id, + nickname: sender.nickname ?? '', + fromEmail: sender.from_email ?? '', + fromName: sender.from_name ?? '', + replyTo: sender.reply_to ?? '', + address: sender.address ?? '', + city: sender.city ?? '', + state: sender.state ?? '', + country: sender.country ?? '', + zip: sender.zip ?? '', + verified: sender.verified ?? false, + locked: sender.locked ?? false, + }), + ); + + return { senders }; + } catch (error) { + throw new RpcException({ + code: status.INTERNAL, + message: `Failed to fetch senders from SendGrid: ${JSON.stringify(error)}`, + }); + } + } + + async getDomains( + req: EmailProto.GetDomainsRequest, + ): Promise { + const config = await lastValueFrom( + this.emailService.getEmailServiceConfig({ + id: Number(req.configId), + environment: req.configEnvironment, + }), + ); + + const sendgridApiKey = config.sendgridKey; + + if (!sendgridApiKey) { + throw new RpcException({ + code: status.FAILED_PRECONDITION, + message: 'Cannot get domains (SendGrid API key is missing)', + }); + } + + if (process.env['NODE_ENV'] == 'test') { + const configKey = `${req.configId}:${req.configEnvironment}`; + return { + domains: this.testDomains.get(configKey) ?? [], + }; + } + + try { + const response = await axios.get( + 'https://api.sendgrid.com/v3/whitelabel/domains', + { + headers: { + Authorization: `Bearer ${sendgridApiKey}`, + 'Content-Type': 'application/json', + }, + }, + ); + + const domains = (response.data ?? []).map( + (domain: SendGridAuthenticatedDomain) => ({ + id: domain.id, + domain: domain.domain ?? '', + subdomain: domain.subdomain ?? undefined, + valid: domain.valid ?? false, + }), + ); + + return { domains }; + } catch (error) { + throw new RpcException({ + code: status.INTERNAL, + message: `Failed to fetch domains from SendGrid: ${JSON.stringify(error)}`, + }); + } + } + async getStatistics( req: EmailProto.GetStatisticsRequest, ): Promise { @@ -409,6 +559,29 @@ const TEST_SENDGRID_RECORDS = { }, }; +// SendGrid API response types +interface SendGridVerifiedSender { + id: number; + nickname: string; + from_email: string; + from_name: string; + reply_to: string; + address: string; + city: string; + state: string; + country: string; + zip: string; + verified: boolean; + locked: boolean; +} + +interface SendGridAuthenticatedDomain { + id: number; + domain: string; + subdomain?: string; + valid: boolean; +} + // Email Metrics Interface interface EmailMetrics { blocks: number; diff --git a/packages/email-service/test/email.e2e-spec.ts b/packages/email-service/test/email.e2e-spec.ts index e31ee960..09fdfcef 100644 --- a/packages/email-service/test/email.e2e-spec.ts +++ b/packages/email-service/test/email.e2e-spec.ts @@ -417,3 +417,131 @@ describe('Email Service Send Email Tests', () => { }); }); }); + +describe('Email Service Get Senders Tests', () => { + let emailClient: any; + beforeEach(async () => { + const proto = ProtoLoader.loadSync([EmailProtoFile], { + arrays: true, + }) as any; + + const protoGRPC = GRPC.loadPackageDefinition(proto) as any; + + emailClient = new protoGRPC.juno.email.EmailService( + process.env.EMAIL_SERVICE_ADDR, + GRPC.credentials.createInsecure(), + ); + }); + + it('should successfully get senders', async () => { + const response: EmailProto.GetSendersResponse = await new Promise( + (resolve, reject) => { + emailClient.getSenders( + { + configId: 0, + configEnvironment: 'prod', + }, + (err: any, response: EmailProto.GetSendersResponse) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }, + ); + }, + ); + + expect(response).toBeDefined(); + expect(response.senders).toBeDefined(); + expect(Array.isArray(response.senders)).toBe(true); + }); + + it('should fail to get senders with invalid config', async () => { + try { + await new Promise((resolve, reject) => { + emailClient.getSenders( + { + configId: 9999, + configEnvironment: 'nonexistent', + }, + (err: any, response: EmailProto.GetSendersResponse) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }, + ); + }); + + fail('Expected an error to be thrown'); + } catch (err) { + expect(err).toBeDefined(); + } + }); +}); + +describe('Email Service Get Domains Tests', () => { + let emailClient: any; + beforeEach(async () => { + const proto = ProtoLoader.loadSync([EmailProtoFile], { + arrays: true, + }) as any; + + const protoGRPC = GRPC.loadPackageDefinition(proto) as any; + + emailClient = new protoGRPC.juno.email.EmailService( + process.env.EMAIL_SERVICE_ADDR, + GRPC.credentials.createInsecure(), + ); + }); + + it('should successfully get domains', async () => { + const response: EmailProto.GetDomainsResponse = await new Promise( + (resolve, reject) => { + emailClient.getDomains( + { + configId: 0, + configEnvironment: 'prod', + }, + (err: any, response: EmailProto.GetDomainsResponse) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }, + ); + }, + ); + + expect(response).toBeDefined(); + expect(response.domains).toBeDefined(); + expect(Array.isArray(response.domains)).toBe(true); + }); + + it('should fail to get domains with invalid config', async () => { + try { + await new Promise((resolve, reject) => { + emailClient.getDomains( + { + configId: 9999, + configEnvironment: 'nonexistent', + }, + (err: any, response: EmailProto.GetDomainsResponse) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }, + ); + }); + + fail('Expected an error to be thrown'); + } catch (err) { + expect(err).toBeDefined(); + } + }); +}); diff --git a/packages/proto/definitions/email.proto b/packages/proto/definitions/email.proto index 43cbf854..6f91d346 100644 --- a/packages/proto/definitions/email.proto +++ b/packages/proto/definitions/email.proto @@ -12,6 +12,8 @@ service EmailService { returns (AuthenticateDomainResponse); rpc verifyDomain(VerifyDomainRequest) returns (VerifyDomainResponse); rpc getStatistics(GetStatisticsRequest) returns (StatisticResponses); + rpc getSenders(GetSendersRequest) returns (GetSendersResponse); + rpc getDomains(GetDomainsRequest) returns (GetDomainsResponse); } service EmailDbService { @@ -231,3 +233,43 @@ message GetEmailServiceConfigRequest { int64 id = 1; string environment = 2; } + +message GetSendersRequest { + int64 configId = 1; + string configEnvironment = 2; +} + +message VerifiedSender { + int64 id = 1; + string nickname = 2; + string from_email = 3; + string from_name = 4; + string reply_to = 5; + string address = 6; + string city = 7; + string state = 8; + string country = 9; + string zip = 10; + bool verified = 11; + bool locked = 12; +} + +message GetSendersResponse { + repeated VerifiedSender senders = 1; +} + +message GetDomainsRequest { + int64 configId = 1; + string configEnvironment = 2; +} + +message AuthenticatedDomain { + int64 id = 1; + string domain = 2; + optional string subdomain = 3; + bool valid = 4; +} + +message GetDomainsResponse { + repeated AuthenticatedDomain domains = 1; +} diff --git a/packages/proto/src/gen/email.ts b/packages/proto/src/gen/email.ts index 42a7ce08..e7a2b270 100644 --- a/packages/proto/src/gen/email.ts +++ b/packages/proto/src/gen/email.ts @@ -225,6 +225,46 @@ export interface GetEmailServiceConfigRequest { environment: string; } +export interface GetSendersRequest { + configId: number; + configEnvironment: string; +} + +export interface VerifiedSender { + id: number; + nickname: string; + fromEmail: string; + fromName: string; + replyTo: string; + address: string; + city: string; + state: string; + country: string; + zip: string; + verified: boolean; + locked: boolean; +} + +export interface GetSendersResponse { + senders: VerifiedSender[]; +} + +export interface GetDomainsRequest { + configId: number; + configEnvironment: string; +} + +export interface AuthenticatedDomain { + id: number; + domain: string; + subdomain?: string | undefined; + valid: boolean; +} + +export interface GetDomainsResponse { + domains: AuthenticatedDomain[]; +} + export const JUNO_EMAIL_PACKAGE_NAME = 'juno.email'; export interface EmailServiceClient { @@ -243,6 +283,10 @@ export interface EmailServiceClient { verifyDomain(request: VerifyDomainRequest): Observable; getStatistics(request: GetStatisticsRequest): Observable; + + getSenders(request: GetSendersRequest): Observable; + + getDomains(request: GetDomainsRequest): Observable; } export interface EmailServiceController { @@ -284,6 +328,20 @@ export interface EmailServiceController { | Promise | Observable | StatisticResponses; + + getSenders( + request: GetSendersRequest, + ): + | Promise + | Observable + | GetSendersResponse; + + getDomains( + request: GetDomainsRequest, + ): + | Promise + | Observable + | GetDomainsResponse; } export function EmailServiceControllerMethods() { @@ -295,6 +353,8 @@ export function EmailServiceControllerMethods() { 'authenticateDomain', 'verifyDomain', 'getStatistics', + 'getSenders', + 'getDomains', ]; for (const method of grpcMethods) { const descriptor: any = Reflect.getOwnPropertyDescriptor(