Skip to content

Commit 9e51634

Browse files
ralyodioclaude
andcommitted
feat(email): add Resend as primary email provider, keep Mailgun as fallback
Resend is used when RESEND_API_KEY is configured; falls back to Mailgun if only MAILGUN vars are set. Adds email router index module with tests. Also fixes @noble/curves v2 import path in SDK wallet module. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 91380d7 commit 9e51634

File tree

9 files changed

+419
-10
lines changed

9 files changed

+419
-10
lines changed

.env.example

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,8 @@ CRON_SECRET=your-cron-secret-here
7777
NODE_ENV=development
7878
PORT=8080
7979

80-
# Mailgun for Email
81-
MAILGUN_API_KEY=your-mailgun-api-key
82-
MAILGUN_DOMAIN=mg.example.com
80+
# Resend for Email
81+
RESEND_API_KEY=your-resend-api-key
8382
REPLY_TO_EMAIL=support@example.com
8483

8584
# WalletConnect / Reown AppKit

PROGRESS.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
- Forwarding Service: 23 tests
110110

111111
#### 11. **Email Notifications** (Complete)
112-
- [x] Mailgun integration
112+
- [x] Resend integration
113113
- [x] Email templates for payment events
114114

115115
**Test Coverage:**
@@ -208,7 +208,7 @@ Test Duration: ~9 seconds
208208
- `src/lib/business/service.ts` + tests (19 tests)
209209
- `src/lib/wallets/service.ts` + tests (20 tests)
210210
- `src/lib/webhooks/service.ts` + tests (21 tests)
211-
- `src/lib/email/mailgun.ts` + tests (10 tests)
211+
- `src/lib/email/resend.ts` + tests (9 tests)
212212
- `src/lib/settings/service.ts` + tests (8 tests)
213213
- `src/lib/analytics.ts` + tests (11 tests)
214214
- `src/lib/blockchain/providers.ts`
@@ -303,9 +303,8 @@ ETHEREUM_RPC_URL=https://eth.llamarpc.com
303303
POLYGON_RPC_URL=https://polygon-rpc.com
304304
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
305305

306-
# Email (Mailgun)
307-
MAILGUN_API_KEY=your_mailgun_api_key
308-
MAILGUN_DOMAIN=your_mailgun_domain
306+
# Email (Resend)
307+
RESEND_API_KEY=your_resend_api_key
309308
```
310309

311310
---

packages/sdk/src/wallet.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import * as bip39 from '@scure/bip39';
1010
import { wordlist } from '@scure/bip39/wordlists/english';
1111
import { HDKey } from '@scure/bip32';
12-
import { secp256k1 } from '@noble/curves/secp256k1';
12+
import { secp256k1 } from '@noble/curves/secp256k1.js';
1313
import { sha256 } from '@noble/hashes/sha2.js';
1414
import { ripemd160 } from '@noble/hashes/ripemd160.js';
1515
import { keccak_256 } from '@noble/hashes/sha3.js';

src/app/settings/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ export default function SettingsPage() {
377377
{/* Additional Info */}
378378
<div className="mt-6 text-center text-sm text-gray-500">
379379
<p>
380-
Email notifications are powered by Mailgun and sent to your
380+
Email notifications are sent to your
381381
registered email address.
382382
</p>
383383
</div>

src/lib/email/index.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
// Mock the provider modules
4+
vi.mock('./resend', () => ({
5+
sendEmail: vi.fn().mockResolvedValue({ success: true, messageId: 'resend_123' }),
6+
sendBulkEmails: vi.fn().mockResolvedValue([{ success: true, messageId: 'resend_123' }]),
7+
}));
8+
9+
vi.mock('./mailgun', () => ({
10+
sendEmail: vi.fn().mockResolvedValue({ success: true, messageId: 'mailgun_123' }),
11+
sendBulkEmails: vi.fn().mockResolvedValue([{ success: true, messageId: 'mailgun_123' }]),
12+
}));
13+
14+
import { sendEmail, sendBulkEmails } from './index';
15+
import { sendEmail as resendSendEmail, sendBulkEmails as resendSendBulkEmails } from './resend';
16+
import { sendEmail as mailgunSendEmail, sendBulkEmails as mailgunSendBulkEmails } from './mailgun';
17+
18+
const testEmail = {
19+
to: 'merchant@example.com',
20+
subject: 'Test Email',
21+
html: '<p>Test content</p>',
22+
};
23+
24+
describe('Email Service Router', () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks();
27+
delete process.env.RESEND_API_KEY;
28+
delete process.env.MAILGUN_API_KEY;
29+
});
30+
31+
describe('sendEmail', () => {
32+
it('should use Resend when RESEND_API_KEY is set', async () => {
33+
process.env.RESEND_API_KEY = 'test-resend-key';
34+
35+
const result = await sendEmail(testEmail);
36+
37+
expect(resendSendEmail).toHaveBeenCalledWith(testEmail);
38+
expect(mailgunSendEmail).not.toHaveBeenCalled();
39+
expect(result.messageId).toBe('resend_123');
40+
});
41+
42+
it('should fall back to Mailgun when RESEND_API_KEY is not set', async () => {
43+
process.env.MAILGUN_API_KEY = 'test-mailgun-key';
44+
45+
const result = await sendEmail(testEmail);
46+
47+
expect(mailgunSendEmail).toHaveBeenCalledWith(testEmail);
48+
expect(resendSendEmail).not.toHaveBeenCalled();
49+
expect(result.messageId).toBe('mailgun_123');
50+
});
51+
52+
it('should prefer Resend when both are configured', async () => {
53+
process.env.RESEND_API_KEY = 'test-resend-key';
54+
process.env.MAILGUN_API_KEY = 'test-mailgun-key';
55+
56+
const result = await sendEmail(testEmail);
57+
58+
expect(resendSendEmail).toHaveBeenCalledWith(testEmail);
59+
expect(mailgunSendEmail).not.toHaveBeenCalled();
60+
expect(result.messageId).toBe('resend_123');
61+
});
62+
});
63+
64+
describe('sendBulkEmails', () => {
65+
const emails = [testEmail, { ...testEmail, to: 'other@example.com' }];
66+
67+
it('should use Resend for bulk when RESEND_API_KEY is set', async () => {
68+
process.env.RESEND_API_KEY = 'test-resend-key';
69+
70+
await sendBulkEmails(emails);
71+
72+
expect(resendSendBulkEmails).toHaveBeenCalledWith(emails);
73+
expect(mailgunSendBulkEmails).not.toHaveBeenCalled();
74+
});
75+
76+
it('should fall back to Mailgun for bulk when RESEND_API_KEY is not set', async () => {
77+
process.env.MAILGUN_API_KEY = 'test-mailgun-key';
78+
79+
await sendBulkEmails(emails);
80+
81+
expect(mailgunSendBulkEmails).toHaveBeenCalledWith(emails);
82+
expect(resendSendBulkEmails).not.toHaveBeenCalled();
83+
});
84+
});
85+
});

src/lib/email/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Email Service
3+
*
4+
* Routes to Resend (preferred) or Mailgun based on which env vars are set.
5+
* Resend is used when RESEND_API_KEY is configured.
6+
* Mailgun is used as a fallback when only MAILGUN_API_KEY + MAILGUN_DOMAIN are configured.
7+
*/
8+
9+
import { sendEmail as resendSendEmail, sendBulkEmails as resendSendBulkEmails } from './resend';
10+
import { sendEmail as mailgunSendEmail, sendBulkEmails as mailgunSendBulkEmails } from './mailgun';
11+
import type { SendEmailInput, SendEmailResult } from './resend';
12+
13+
export type { SendEmailInput, SendEmailResult };
14+
15+
function useResend(): boolean {
16+
return !!process.env.RESEND_API_KEY;
17+
}
18+
19+
export async function sendEmail(input: SendEmailInput): Promise<SendEmailResult> {
20+
if (useResend()) {
21+
return resendSendEmail(input);
22+
}
23+
return mailgunSendEmail(input);
24+
}
25+
26+
export async function sendBulkEmails(emails: SendEmailInput[]): Promise<SendEmailResult[]> {
27+
if (useResend()) {
28+
return resendSendBulkEmails(emails);
29+
}
30+
return mailgunSendBulkEmails(emails);
31+
}

src/lib/email/resend.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { sendEmail } from './resend';
3+
4+
// Mock fetch globally
5+
global.fetch = vi.fn();
6+
7+
describe('Resend Email Service', () => {
8+
beforeEach(() => {
9+
vi.clearAllMocks();
10+
// Set required environment variables
11+
process.env.RESEND_API_KEY = 'test-api-key';
12+
process.env.REPLY_TO_EMAIL = 'support@example.com';
13+
});
14+
15+
describe('sendEmail', () => {
16+
it('should send email successfully', async () => {
17+
const mockResponse = {
18+
ok: true,
19+
json: async () => ({ id: 'msg_123' }),
20+
};
21+
(global.fetch as any).mockResolvedValue(mockResponse);
22+
23+
const result = await sendEmail({
24+
to: 'merchant@example.com',
25+
subject: 'Test Email',
26+
html: '<p>Test content</p>',
27+
});
28+
29+
expect(result.success).toBe(true);
30+
expect(result.messageId).toBe('msg_123');
31+
expect(global.fetch).toHaveBeenCalledTimes(1);
32+
33+
const fetchCall = (global.fetch as any).mock.calls[0];
34+
expect(fetchCall[0]).toBe('https://api.resend.com/emails');
35+
expect(fetchCall[1].method).toBe('POST');
36+
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
37+
expect(fetchCall[1].headers['Authorization']).toBe('Bearer test-api-key');
38+
});
39+
40+
it('should handle missing API key', async () => {
41+
delete process.env.RESEND_API_KEY;
42+
43+
const result = await sendEmail({
44+
to: 'merchant@example.com',
45+
subject: 'Test Email',
46+
html: '<p>Test content</p>',
47+
});
48+
49+
expect(result.success).toBe(false);
50+
expect(result.error).toContain('Resend API key');
51+
expect(global.fetch).not.toHaveBeenCalled();
52+
});
53+
54+
it('should validate email address', async () => {
55+
const result = await sendEmail({
56+
to: 'invalid-email',
57+
subject: 'Test Email',
58+
html: '<p>Test content</p>',
59+
});
60+
61+
expect(result.success).toBe(false);
62+
expect(result.error).toContain('Invalid email address');
63+
expect(global.fetch).not.toHaveBeenCalled();
64+
});
65+
66+
it('should validate subject is not empty', async () => {
67+
const result = await sendEmail({
68+
to: 'merchant@example.com',
69+
subject: '',
70+
html: '<p>Test content</p>',
71+
});
72+
73+
expect(result.success).toBe(false);
74+
expect(result.error).toContain('Subject is required');
75+
expect(global.fetch).not.toHaveBeenCalled();
76+
});
77+
78+
it('should validate HTML content is not empty', async () => {
79+
const result = await sendEmail({
80+
to: 'merchant@example.com',
81+
subject: 'Test Email',
82+
html: '',
83+
});
84+
85+
expect(result.success).toBe(false);
86+
expect(result.error).toContain('HTML content is required');
87+
expect(global.fetch).not.toHaveBeenCalled();
88+
});
89+
90+
it('should handle Resend API errors', async () => {
91+
const mockResponse = {
92+
ok: false,
93+
status: 400,
94+
json: async () => ({ message: 'Invalid request' }),
95+
};
96+
(global.fetch as any).mockResolvedValue(mockResponse);
97+
98+
const result = await sendEmail({
99+
to: 'merchant@example.com',
100+
subject: 'Test Email',
101+
html: '<p>Test content</p>',
102+
});
103+
104+
expect(result.success).toBe(false);
105+
expect(result.error).toContain('Invalid request');
106+
});
107+
108+
it('should handle network errors', async () => {
109+
(global.fetch as any).mockRejectedValue(new Error('Network error'));
110+
111+
const result = await sendEmail({
112+
to: 'merchant@example.com',
113+
subject: 'Test Email',
114+
html: '<p>Test content</p>',
115+
});
116+
117+
expect(result.success).toBe(false);
118+
expect(result.error).toContain('Network error');
119+
});
120+
121+
it('should send correct JSON body', async () => {
122+
const mockResponse = {
123+
ok: true,
124+
json: async () => ({ id: 'msg_123' }),
125+
};
126+
(global.fetch as any).mockResolvedValue(mockResponse);
127+
128+
await sendEmail({
129+
to: 'merchant@example.com',
130+
subject: 'Test Email',
131+
html: '<p>Test content</p>',
132+
});
133+
134+
const fetchCall = (global.fetch as any).mock.calls[0];
135+
const body = JSON.parse(fetchCall[1].body);
136+
expect(body.to).toEqual(['merchant@example.com']);
137+
expect(body.subject).toBe('Test Email');
138+
expect(body.html).toBe('<p>Test content</p>');
139+
expect(body.reply_to).toBe('support@example.com');
140+
expect(body.from).toContain('CoinPay');
141+
});
142+
143+
it('should use custom from address when provided', async () => {
144+
const mockResponse = {
145+
ok: true,
146+
json: async () => ({ id: 'msg_123' }),
147+
};
148+
(global.fetch as any).mockResolvedValue(mockResponse);
149+
150+
await sendEmail({
151+
to: 'merchant@example.com',
152+
subject: 'Test Email',
153+
html: '<p>Test content</p>',
154+
from: 'Custom <custom@example.com>',
155+
});
156+
157+
const fetchCall = (global.fetch as any).mock.calls[0];
158+
const body = JSON.parse(fetchCall[1].body);
159+
expect(body.from).toBe('Custom <custom@example.com>');
160+
});
161+
});
162+
});

0 commit comments

Comments
 (0)