@open-kingdom/shared-backend-feature-email 0.0.2-7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.spec.swcrc +22 -0
  2. package/README.md +11 -0
  3. package/dist/index.d.ts +10 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +6 -0
  6. package/dist/lib/email.controller.d.ts +8 -0
  7. package/dist/lib/email.controller.d.ts.map +1 -0
  8. package/dist/lib/email.controller.js +53 -0
  9. package/dist/lib/email.d.ts +12 -0
  10. package/dist/lib/email.d.ts.map +1 -0
  11. package/dist/lib/email.dto.d.ts +11 -0
  12. package/dist/lib/email.dto.d.ts.map +1 -0
  13. package/dist/lib/email.dto.js +51 -0
  14. package/dist/lib/email.js +34 -0
  15. package/dist/lib/email.service.d.ts +8 -0
  16. package/dist/lib/email.service.d.ts.map +1 -0
  17. package/dist/lib/email.service.js +27 -0
  18. package/dist/lib/email.types.d.ts +15 -0
  19. package/dist/lib/email.types.d.ts.map +1 -0
  20. package/dist/lib/email.types.js +2 -0
  21. package/dist/lib/providers/gmail.provider.d.ts +15 -0
  22. package/dist/lib/providers/gmail.provider.d.ts.map +1 -0
  23. package/dist/lib/providers/gmail.provider.js +43 -0
  24. package/dist/lib/providers/index.d.ts +3 -0
  25. package/dist/lib/providers/index.d.ts.map +1 -0
  26. package/dist/lib/providers/index.js +1 -0
  27. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  28. package/jest.config.cts +20 -0
  29. package/package.json +36 -0
  30. package/src/index.ts +16 -0
  31. package/src/lib/email.controller.spec.ts +36 -0
  32. package/src/lib/email.controller.ts +40 -0
  33. package/src/lib/email.dto.ts +44 -0
  34. package/src/lib/email.service.spec.ts +52 -0
  35. package/src/lib/email.service.ts +24 -0
  36. package/src/lib/email.spec.ts +30 -0
  37. package/src/lib/email.ts +45 -0
  38. package/src/lib/email.types.ts +20 -0
  39. package/src/lib/providers/gmail.provider.spec.ts +142 -0
  40. package/src/lib/providers/gmail.provider.ts +63 -0
  41. package/src/lib/providers/index.ts +2 -0
  42. package/tsconfig.json +13 -0
  43. package/tsconfig.lib.json +29 -0
  44. package/tsconfig.spec.json +22 -0
@@ -0,0 +1,52 @@
1
+ import { Test } from '@nestjs/testing';
2
+ import { EmailService } from './email.service.js';
3
+ import { EMAIL_PROVIDER } from './email.types.js';
4
+
5
+ describe('EmailService', () => {
6
+ let service: EmailService;
7
+ let mockProvider: { send: jest.Mock };
8
+
9
+ beforeEach(async () => {
10
+ mockProvider = { send: jest.fn() };
11
+
12
+ const module = await Test.createTestingModule({
13
+ providers: [
14
+ EmailService,
15
+ { provide: EMAIL_PROVIDER, useValue: mockProvider },
16
+ ],
17
+ }).compile();
18
+
19
+ service = module.get(EmailService);
20
+ });
21
+
22
+ it('sends email and returns success with message ID', async () => {
23
+ mockProvider.send.mockResolvedValue({ messageId: 'msg-123' });
24
+
25
+ const result = await service.send({
26
+ to: 'recipient@example.com',
27
+ subject: 'Test Subject',
28
+ body: 'Hello, this is a test.',
29
+ });
30
+
31
+ expect(result.success).toBe(true);
32
+ expect(result.messageId).toBe('msg-123');
33
+ expect(mockProvider.send).toHaveBeenCalledWith({
34
+ to: ['recipient@example.com'],
35
+ subject: 'Test Subject',
36
+ text: 'Hello, this is a test.',
37
+ });
38
+ });
39
+
40
+ it('returns error when email fails to send', async () => {
41
+ mockProvider.send.mockRejectedValue(new Error('SMTP connection failed'));
42
+
43
+ const result = await service.send({
44
+ to: 'recipient@example.com',
45
+ subject: 'Test',
46
+ body: 'Test body',
47
+ });
48
+
49
+ expect(result.success).toBe(false);
50
+ expect(result.error).toBe('SMTP connection failed');
51
+ });
52
+ });
@@ -0,0 +1,24 @@
1
+ import { Injectable, Inject } from '@nestjs/common';
2
+ import { EMAIL_PROVIDER } from './email.types.js';
3
+ import type { EmailProvider } from './email.types.js';
4
+ import { SendEmailDto, SendEmailResponseDto } from './email.dto.js';
5
+
6
+ @Injectable()
7
+ export class EmailService {
8
+ constructor(
9
+ @Inject(EMAIL_PROVIDER) private readonly emailProvider: EmailProvider
10
+ ) {}
11
+
12
+ async send(dto: SendEmailDto): Promise<SendEmailResponseDto> {
13
+ try {
14
+ const result = await this.emailProvider.send({
15
+ to: [dto.to],
16
+ subject: dto.subject,
17
+ text: dto.body,
18
+ });
19
+ return { success: true, messageId: result.messageId };
20
+ } catch (error) {
21
+ return { success: false, error: (error as Error).message };
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,30 @@
1
+ // Mock dependencies before imports to avoid circular dependency issues
2
+ jest.mock('./email.service.js', () => ({ EmailService: jest.fn() }));
3
+ jest.mock('./email.controller.js', () => ({ EmailController: jest.fn() }));
4
+ jest.mock('./providers/gmail.provider.js', () => ({
5
+ GmailProvider: jest.fn().mockImplementation(() => ({
6
+ send: jest.fn().mockResolvedValue({ messageId: 'test-123' }),
7
+ })),
8
+ }));
9
+
10
+ import { EmailModule } from './email.js';
11
+
12
+ describe('EmailModule', () => {
13
+ const gmailConfig = {
14
+ clientEmail: 'test@example.com',
15
+ privateKey: '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----',
16
+ impersonateEmail: 'sender@example.com',
17
+ };
18
+
19
+ it('configures module with gmail provider', () => {
20
+ const result = EmailModule.forRoot({
21
+ provider: 'gmail',
22
+ config: gmailConfig,
23
+ });
24
+
25
+ expect(result.module).toBe(EmailModule);
26
+ expect(result.controllers).toHaveLength(1);
27
+ expect(result.providers).toHaveLength(2);
28
+ expect(result.exports).toHaveLength(1);
29
+ });
30
+ });
@@ -0,0 +1,45 @@
1
+ import { Module, DynamicModule } from '@nestjs/common';
2
+ import {
3
+ GmailProvider,
4
+ GmailProviderConfig,
5
+ } from './providers/gmail.provider.js';
6
+ import { EmailService } from './email.service.js';
7
+ import { EmailController } from './email.controller.js';
8
+ import { EMAIL_PROVIDER } from './email.types.js';
9
+
10
+ export { EMAIL_PROVIDER } from './email.types.js';
11
+ export type {
12
+ EmailMessage,
13
+ EmailResult,
14
+ EmailProvider,
15
+ } from './email.types.js';
16
+
17
+ // Extensible for future providers
18
+ export type EmailModuleOptions = {
19
+ provider: 'gmail';
20
+ config: GmailProviderConfig;
21
+ };
22
+
23
+ // Module
24
+ @Module({})
25
+ export class EmailModule {
26
+ static forRoot(options: EmailModuleOptions): DynamicModule {
27
+ return {
28
+ module: EmailModule,
29
+ controllers: [EmailController],
30
+ providers: [
31
+ {
32
+ provide: EMAIL_PROVIDER,
33
+ useFactory: () => {
34
+ switch (options.provider) {
35
+ case 'gmail':
36
+ return new GmailProvider(options.config);
37
+ }
38
+ },
39
+ },
40
+ EmailService,
41
+ ],
42
+ exports: [EmailService],
43
+ };
44
+ }
45
+ }
@@ -0,0 +1,20 @@
1
+ // Types
2
+ export interface EmailMessage {
3
+ to: string[];
4
+ from?: string;
5
+ subject: string;
6
+ text?: string;
7
+ html?: string;
8
+ }
9
+
10
+ export interface EmailResult {
11
+ messageId?: string;
12
+ }
13
+
14
+ // Provider interface
15
+ export interface EmailProvider {
16
+ send(message: EmailMessage): Promise<EmailResult>;
17
+ }
18
+
19
+ // DI token
20
+ export const EMAIL_PROVIDER = Symbol('EMAIL_PROVIDER');
@@ -0,0 +1,142 @@
1
+ import { GmailProvider, GmailProviderConfig } from './gmail.provider.js';
2
+
3
+ // Mock @googleapis/gmail
4
+ jest.mock('@googleapis/gmail', () => ({
5
+ gmail: jest.fn().mockReturnValue({
6
+ users: {
7
+ messages: {
8
+ send: jest.fn(),
9
+ },
10
+ },
11
+ }),
12
+ auth: {
13
+ JWT: jest.fn().mockImplementation(() => ({})),
14
+ },
15
+ }));
16
+
17
+ describe('GmailProvider', () => {
18
+ const config: GmailProviderConfig = {
19
+ clientEmail: 'test@project.iam.gserviceaccount.com',
20
+ privateKey: '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----',
21
+ impersonateEmail: 'sender@example.com',
22
+ };
23
+
24
+ let provider: GmailProvider;
25
+ let mockSend: jest.Mock;
26
+
27
+ beforeEach(() => {
28
+ jest.clearAllMocks();
29
+ const { gmail } = jest.requireMock('@googleapis/gmail');
30
+ mockSend = gmail().users.messages.send;
31
+ provider = new GmailProvider(config);
32
+ });
33
+
34
+ describe('authentication', () => {
35
+ it('authenticates with gmail send scope', () => {
36
+ const { auth } = jest.requireMock('@googleapis/gmail');
37
+ expect(auth.JWT).toHaveBeenCalledWith({
38
+ email: config.clientEmail,
39
+ key: config.privateKey,
40
+ scopes: ['https://www.googleapis.com/auth/gmail.send'],
41
+ subject: config.impersonateEmail,
42
+ });
43
+ });
44
+
45
+ it('converts escaped newlines in private key', () => {
46
+ const escapedConfig: GmailProviderConfig = {
47
+ ...config,
48
+ privateKey:
49
+ '-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----',
50
+ };
51
+ new GmailProvider(escapedConfig);
52
+
53
+ const { auth } = jest.requireMock('@googleapis/gmail');
54
+ expect(auth.JWT).toHaveBeenLastCalledWith(
55
+ expect.objectContaining({
56
+ key: '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----',
57
+ })
58
+ );
59
+ });
60
+ });
61
+
62
+ describe('sending emails', () => {
63
+ it('sends plain text email', async () => {
64
+ mockSend.mockResolvedValue({ data: { id: 'msg-123' } });
65
+
66
+ const result = await provider.send({
67
+ to: ['recipient@example.com'],
68
+ subject: 'Test Subject',
69
+ text: 'Hello World',
70
+ });
71
+
72
+ expect(result).toEqual({ messageId: 'msg-123' });
73
+ });
74
+
75
+ it('sends HTML email', async () => {
76
+ mockSend.mockResolvedValue({ data: { id: 'msg-456' } });
77
+
78
+ const result = await provider.send({
79
+ to: ['recipient@example.com'],
80
+ subject: 'Test Subject',
81
+ html: '<h1>Hello World</h1>',
82
+ });
83
+
84
+ expect(result).toEqual({ messageId: 'msg-456' });
85
+ });
86
+
87
+ it('sends to multiple recipients', async () => {
88
+ mockSend.mockResolvedValue({ data: { id: 'msg-102' } });
89
+
90
+ await provider.send({
91
+ to: ['first@example.com', 'second@example.com'],
92
+ subject: 'Test',
93
+ text: 'Hello',
94
+ });
95
+
96
+ const call = mockSend.mock.calls[0][0];
97
+ const rawDecoded = Buffer.from(call.requestBody.raw, 'base64').toString();
98
+ expect(rawDecoded).toContain('To: first@example.com, second@example.com');
99
+ });
100
+
101
+ it('uses impersonate email as default sender', async () => {
102
+ mockSend.mockResolvedValue({ data: { id: 'msg-789' } });
103
+
104
+ await provider.send({
105
+ to: ['recipient@example.com'],
106
+ subject: 'Test',
107
+ text: 'Hello',
108
+ });
109
+
110
+ const call = mockSend.mock.calls[0][0];
111
+ const rawDecoded = Buffer.from(call.requestBody.raw, 'base64').toString();
112
+ expect(rawDecoded).toContain(`From: ${config.impersonateEmail}`);
113
+ });
114
+
115
+ it('allows custom sender address', async () => {
116
+ mockSend.mockResolvedValue({ data: { id: 'msg-101' } });
117
+
118
+ await provider.send({
119
+ to: ['recipient@example.com'],
120
+ from: 'custom@example.com',
121
+ subject: 'Test',
122
+ text: 'Hello',
123
+ });
124
+
125
+ const call = mockSend.mock.calls[0][0];
126
+ const rawDecoded = Buffer.from(call.requestBody.raw, 'base64').toString();
127
+ expect(rawDecoded).toContain('From: custom@example.com');
128
+ });
129
+
130
+ it('confirms delivery when Gmail responds without full details', async () => {
131
+ mockSend.mockResolvedValue({ data: {} });
132
+
133
+ const result = await provider.send({
134
+ to: ['recipient@example.com'],
135
+ subject: 'Test',
136
+ text: 'Hello',
137
+ });
138
+
139
+ expect(result).toBeDefined();
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,63 @@
1
+ import { gmail, auth } from '@googleapis/gmail';
2
+ import { EmailMessage, EmailResult, EmailProvider } from '../email.js';
3
+
4
+ export interface GmailProviderConfig {
5
+ clientEmail: string;
6
+ privateKey: string;
7
+ impersonateEmail: string;
8
+ }
9
+
10
+ export class GmailProvider implements EmailProvider {
11
+ private client: ReturnType<typeof gmail>;
12
+ private defaultFrom: string;
13
+
14
+ constructor(config: GmailProviderConfig) {
15
+ const jwt = new auth.JWT({
16
+ email: config.clientEmail,
17
+ key: config.privateKey.replace(/\\n/g, '\n'),
18
+ scopes: ['https://www.googleapis.com/auth/gmail.send'],
19
+ subject: config.impersonateEmail,
20
+ });
21
+
22
+ this.client = gmail({ version: 'v1', auth: jwt });
23
+ this.defaultFrom = config.impersonateEmail;
24
+ }
25
+
26
+ async send(message: EmailMessage): Promise<EmailResult> {
27
+ const raw = this.buildRawMessage(message);
28
+
29
+ const res = await this.client.users.messages.send({
30
+ userId: 'me',
31
+ requestBody: { raw },
32
+ });
33
+
34
+ return { messageId: res.data.id || undefined };
35
+ }
36
+
37
+ private buildRawMessage(message: EmailMessage): string {
38
+ const from = message.from || this.defaultFrom;
39
+ const to = message.to.join(', ');
40
+ const contentType = message.html ? 'text/html' : 'text/plain';
41
+ const body = message.html || message.text || '';
42
+
43
+ const headers = [
44
+ `From: ${from}`,
45
+ `To: ${to}`,
46
+ `Subject: ${message.subject}`,
47
+ 'MIME-Version: 1.0',
48
+ `Content-Type: ${contentType}; charset=UTF-8`,
49
+ ].join('\r\n');
50
+
51
+ const rawMessage = `${headers}\r\n\r\n${body}`;
52
+
53
+ return this.toBase64Url(rawMessage);
54
+ }
55
+
56
+ private toBase64Url(str: string): string {
57
+ return Buffer.from(str)
58
+ .toString('base64')
59
+ .replace(/\+/g, '-')
60
+ .replace(/\//g, '_')
61
+ .replace(/=+$/, '');
62
+ }
63
+ }
@@ -0,0 +1,2 @@
1
+ export { GmailProvider } from './gmail.provider.js';
2
+ export type { GmailProviderConfig } from './gmail.provider.js';
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../../../tsconfig.base.json",
3
+ "files": [],
4
+ "include": [],
5
+ "references": [
6
+ {
7
+ "path": "./tsconfig.lib.json"
8
+ },
9
+ {
10
+ "path": "./tsconfig.spec.json"
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "extends": "../../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "rootDir": "src",
6
+ "outDir": "dist",
7
+ "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
8
+ "emitDeclarationOnly": false,
9
+ "module": "nodenext",
10
+ "moduleResolution": "nodenext",
11
+ "forceConsistentCasingInFileNames": true,
12
+ "types": ["node"],
13
+ "target": "es2021",
14
+ "experimentalDecorators": true,
15
+ "emitDecoratorMetadata": true,
16
+ "strictNullChecks": true,
17
+ "noImplicitAny": true,
18
+ "strictBindCallApply": true,
19
+ "noFallthroughCasesInSwitch": true
20
+ },
21
+ "include": ["src/**/*.ts"],
22
+ "references": [],
23
+ "exclude": [
24
+ "jest.config.ts",
25
+ "jest.config.cts",
26
+ "src/**/*.spec.ts",
27
+ "src/**/*.test.ts"
28
+ ]
29
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "extends": "../../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./out-tsc/jest",
5
+ "types": ["jest", "node"],
6
+ "module": "nodenext",
7
+ "moduleResolution": "nodenext",
8
+ "forceConsistentCasingInFileNames": true
9
+ },
10
+ "include": [
11
+ "jest.config.ts",
12
+ "jest.config.cts",
13
+ "src/**/*.test.ts",
14
+ "src/**/*.spec.ts",
15
+ "src/**/*.d.ts"
16
+ ],
17
+ "references": [
18
+ {
19
+ "path": "./tsconfig.lib.json"
20
+ }
21
+ ]
22
+ }