Skip to content

Commit b0b1aae

Browse files
committed
nodemailer
1 parent 70b1626 commit b0b1aae

7 files changed

Lines changed: 367 additions & 8 deletions

File tree

apps/backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@
2525
"better-auth": "^1.5.6",
2626
"drizzle-orm": "^0.45.2",
2727
"hono": "^4.12.9",
28+
"nodemailer": "^8.0.5",
2829
"pg": "^8.13.3",
2930
"zod": "^4.3.6"
3031
},
3132
"devDependencies": {
3233
"@types/node": "^25.5.0",
34+
"@types/nodemailer": "^8.0.0",
3335
"@types/pg": "^8.20.0",
3436
"drizzle-kit": "^0.31.10",
3537
"tsx": "^4.19.3"

apps/backend/src/lib/config.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
type Env = {
1+
export type Env = {
22
DATABASE_URL: string;
33
PORT: number;
44
BETTER_AUTH_SECRET: string;
@@ -12,9 +12,16 @@ type Env = {
1212
S3_BUCKET_RULES: string;
1313
S3_BUCKET_SUBMISSIONS: string;
1414
S3_FORCE_PATH_STYLE: boolean;
15-
EMAIL_PROVIDER: 'console' | 'sendgrid';
15+
EMAIL_PROVIDER: 'console' | 'sendgrid' | 'smtp';
1616
SENDGRID_API_KEY?: string;
1717
SENDGRID_FROM: string;
18+
SMTP_HOST?: string;
19+
SMTP_PORT: number;
20+
SMTP_USER?: string;
21+
SMTP_PASS?: string;
22+
SMTP_FROM?: string;
23+
SMTP_SECURE: boolean;
24+
SMTP_REQUIRE_TLS: boolean;
1825
};
1926

2027
const toBool = (value: string | undefined, fallback: boolean): boolean => {
@@ -51,7 +58,17 @@ export const env: Env = {
5158
S3_BUCKET_RULES: process.env.S3_BUCKET_RULES ?? 'robocon-rules',
5259
S3_BUCKET_SUBMISSIONS: process.env.S3_BUCKET_SUBMISSIONS ?? 'robocon-submissions',
5360
S3_FORCE_PATH_STYLE: toBool(process.env.S3_FORCE_PATH_STYLE, true),
54-
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER === 'sendgrid' ? 'sendgrid' : 'console',
61+
EMAIL_PROVIDER:
62+
process.env.EMAIL_PROVIDER === 'sendgrid' || process.env.EMAIL_PROVIDER === 'smtp'
63+
? process.env.EMAIL_PROVIDER
64+
: 'console',
5565
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
5666
SENDGRID_FROM: process.env.SENDGRID_FROM ?? 'noreply@example.com',
67+
SMTP_HOST: process.env.SMTP_HOST,
68+
SMTP_PORT: Number(process.env.SMTP_PORT ?? 587),
69+
SMTP_USER: process.env.SMTP_USER,
70+
SMTP_PASS: process.env.SMTP_PASS,
71+
SMTP_FROM: process.env.SMTP_FROM,
72+
SMTP_SECURE: toBool(process.env.SMTP_SECURE, false),
73+
SMTP_REQUIRE_TLS: toBool(process.env.SMTP_REQUIRE_TLS, false),
5774
};
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { Env } from '../../lib/config.js';
3+
4+
const createTransportMock = vi.fn(() => ({
5+
sendMail: vi.fn(),
6+
}));
7+
const setApiKeyMock = vi.fn();
8+
9+
vi.mock('nodemailer', () => ({
10+
default: {
11+
createTransport: createTransportMock,
12+
},
13+
}));
14+
15+
vi.mock('@sendgrid/mail', () => ({
16+
default: {
17+
send: vi.fn(),
18+
setApiKey: setApiKeyMock,
19+
},
20+
}));
21+
22+
const createEnv = (overrides: Partial<Env> = {}): Env => ({
23+
DATABASE_URL: 'postgres://robocon:password@localhost:5432/robocon',
24+
PORT: 8787,
25+
BETTER_AUTH_SECRET: 'dev-secret',
26+
BETTER_AUTH_URL: 'http://localhost:8787',
27+
APP_URL: 'http://localhost:3000',
28+
CORS_ALLOWED_ORIGINS: ['http://localhost:3000'],
29+
S3_ENDPOINT: 'http://localhost:9000',
30+
S3_REGION: 'us-east-1',
31+
S3_ACCESS_KEY: 'minioadmin',
32+
S3_SECRET_KEY: 'minioadmin',
33+
S3_BUCKET_RULES: 'robocon-rules',
34+
S3_BUCKET_SUBMISSIONS: 'robocon-submissions',
35+
S3_FORCE_PATH_STYLE: true,
36+
EMAIL_PROVIDER: 'console',
37+
SENDGRID_API_KEY: undefined,
38+
SENDGRID_FROM: 'sendgrid@example.com',
39+
SMTP_HOST: undefined,
40+
SMTP_PORT: 587,
41+
SMTP_USER: undefined,
42+
SMTP_PASS: undefined,
43+
SMTP_FROM: undefined,
44+
SMTP_SECURE: false,
45+
SMTP_REQUIRE_TLS: false,
46+
...overrides,
47+
});
48+
49+
describe('createEmailService', () => {
50+
beforeEach(() => {
51+
vi.clearAllMocks();
52+
});
53+
54+
it('uses console email by default', async () => {
55+
const { createEmailService } = await import('./index.js');
56+
const { ConsoleEmailService } = await import('./console.js');
57+
58+
expect(createEmailService(createEnv())).toBeInstanceOf(ConsoleEmailService);
59+
});
60+
61+
it('uses SendGrid when configured with an API key', async () => {
62+
const { createEmailService } = await import('./index.js');
63+
const { SendGridEmailService } = await import('./sendgrid.js');
64+
65+
const service = createEmailService(
66+
createEnv({
67+
EMAIL_PROVIDER: 'sendgrid',
68+
SENDGRID_API_KEY: 'sendgrid-key',
69+
}),
70+
);
71+
72+
expect(service).toBeInstanceOf(SendGridEmailService);
73+
expect(setApiKeyMock).toHaveBeenCalledWith('sendgrid-key');
74+
});
75+
76+
it('keeps the existing SendGrid fallback when the API key is missing', async () => {
77+
const { createEmailService } = await import('./index.js');
78+
const { ConsoleEmailService } = await import('./console.js');
79+
80+
expect(createEmailService(createEnv({ EMAIL_PROVIDER: 'sendgrid' }))).toBeInstanceOf(
81+
ConsoleEmailService,
82+
);
83+
});
84+
85+
it('uses SMTP when all required SMTP settings are present', async () => {
86+
const { createEmailService } = await import('./index.js');
87+
const { SmtpEmailService } = await import('./smtp.js');
88+
89+
const service = createEmailService(
90+
createEnv({
91+
EMAIL_PROVIDER: 'smtp',
92+
SMTP_HOST: 'smtp.example.com',
93+
SMTP_PORT: 465,
94+
SMTP_USER: 'smtp-user',
95+
SMTP_PASS: 'smtp-pass',
96+
SMTP_FROM: 'from@example.com',
97+
SMTP_SECURE: true,
98+
SMTP_REQUIRE_TLS: true,
99+
}),
100+
);
101+
102+
expect(service).toBeInstanceOf(SmtpEmailService);
103+
expect(createTransportMock).toHaveBeenCalledWith({
104+
host: 'smtp.example.com',
105+
port: 465,
106+
secure: true,
107+
auth: {
108+
user: 'smtp-user',
109+
pass: 'smtp-pass',
110+
},
111+
requireTLS: true,
112+
});
113+
});
114+
115+
it('throws when SMTP provider is missing required settings', async () => {
116+
const { createEmailService } = await import('./index.js');
117+
118+
expect(() => createEmailService(createEnv({ EMAIL_PROVIDER: 'smtp' }))).toThrow(
119+
'EMAIL_PROVIDER=smtp requires SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_FROM.',
120+
);
121+
});
122+
});
Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,53 @@
1-
import { env } from '../../lib/config.js';
1+
import { type Env, env } from '../../lib/config.js';
22
import { ConsoleEmailService } from './console.js';
33
import type { EmailService } from './interface.js';
44
import { SendGridEmailService } from './sendgrid.js';
5+
import { SmtpEmailService } from './smtp.js';
56

6-
export const emailService: EmailService =
7-
env.EMAIL_PROVIDER === 'sendgrid' && env.SENDGRID_API_KEY
8-
? new SendGridEmailService(env.SENDGRID_API_KEY, env.SENDGRID_FROM)
9-
: new ConsoleEmailService();
7+
export const createEmailService = (config: Env): EmailService => {
8+
if (config.EMAIL_PROVIDER === 'sendgrid' && config.SENDGRID_API_KEY) {
9+
return new SendGridEmailService(config.SENDGRID_API_KEY, config.SENDGRID_FROM);
10+
}
11+
12+
if (config.EMAIL_PROVIDER === 'smtp') {
13+
const smtpConfig = {
14+
host: config.SMTP_HOST,
15+
port: config.SMTP_PORT,
16+
user: config.SMTP_USER,
17+
pass: config.SMTP_PASS,
18+
from: config.SMTP_FROM,
19+
secure: config.SMTP_SECURE,
20+
requireTLS: config.SMTP_REQUIRE_TLS,
21+
};
22+
const missing = [
23+
['SMTP_HOST', smtpConfig.host],
24+
['SMTP_USER', smtpConfig.user],
25+
['SMTP_PASS', smtpConfig.pass],
26+
['SMTP_FROM', smtpConfig.from],
27+
]
28+
.filter(([, value]) => !value)
29+
.map(([name]) => name);
30+
31+
if (missing.length > 0) {
32+
throw new Error(`EMAIL_PROVIDER=smtp requires ${missing.join(', ')}.`);
33+
}
34+
35+
if (!smtpConfig.host || !smtpConfig.user || !smtpConfig.pass || !smtpConfig.from) {
36+
throw new Error('EMAIL_PROVIDER=smtp requires valid SMTP settings.');
37+
}
38+
39+
return new SmtpEmailService({
40+
host: smtpConfig.host,
41+
port: smtpConfig.port,
42+
user: smtpConfig.user,
43+
pass: smtpConfig.pass,
44+
from: smtpConfig.from,
45+
secure: smtpConfig.secure,
46+
requireTLS: smtpConfig.requireTLS,
47+
});
48+
}
49+
50+
return new ConsoleEmailService();
51+
};
52+
53+
export const emailService: EmailService = createEmailService(env);
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const sendMailMock = vi.fn();
4+
const createTransportMock = vi.fn(() => ({
5+
sendMail: sendMailMock,
6+
}));
7+
8+
vi.mock('nodemailer', () => ({
9+
default: {
10+
createTransport: createTransportMock,
11+
},
12+
}));
13+
14+
describe('SmtpEmailService', () => {
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
sendMailMock.mockResolvedValue({
18+
messageId: 'smtp-message-1',
19+
});
20+
});
21+
22+
it('creates a transporter with SMTP settings', async () => {
23+
const { SmtpEmailService } = await import('./smtp.js');
24+
25+
new SmtpEmailService({
26+
host: 'smtp.example.com',
27+
port: 465,
28+
user: 'smtp-user',
29+
pass: 'smtp-pass',
30+
from: 'from@example.com',
31+
secure: true,
32+
requireTLS: true,
33+
});
34+
35+
expect(createTransportMock).toHaveBeenCalledWith({
36+
host: 'smtp.example.com',
37+
port: 465,
38+
secure: true,
39+
auth: {
40+
user: 'smtp-user',
41+
pass: 'smtp-pass',
42+
},
43+
requireTLS: true,
44+
});
45+
});
46+
47+
it('sends raw subject/html emails as-is', async () => {
48+
const { SmtpEmailService } = await import('./smtp.js');
49+
const service = new SmtpEmailService({
50+
host: 'smtp.example.com',
51+
port: 587,
52+
user: 'smtp-user',
53+
pass: 'smtp-pass',
54+
from: 'from@example.com',
55+
secure: false,
56+
requireTLS: false,
57+
});
58+
59+
const result = await service.sendEmail({
60+
to: 'owner@example.com',
61+
subject: 'Hello',
62+
html: '<p>World</p>',
63+
text: 'World',
64+
});
65+
66+
expect(sendMailMock).toHaveBeenCalledWith({
67+
to: 'owner@example.com',
68+
from: 'from@example.com',
69+
subject: 'Hello',
70+
html: '<p>World</p>',
71+
text: 'World',
72+
});
73+
expect(result).toEqual({
74+
success: true,
75+
messageId: 'smtp-message-1',
76+
});
77+
});
78+
79+
it('resolves template-based emails before sending', async () => {
80+
const { SmtpEmailService } = await import('./smtp.js');
81+
const service = new SmtpEmailService({
82+
host: 'smtp.example.com',
83+
port: 587,
84+
user: 'smtp-user',
85+
pass: 'smtp-pass',
86+
from: 'from@example.com',
87+
secure: false,
88+
requireTLS: true,
89+
});
90+
91+
await service.sendEmail({
92+
to: 'owner@example.com',
93+
template: 'university-owner-invitation-link',
94+
payload: {
95+
universityName: 'Approve University',
96+
invitationLink: 'https://app.example.test/invite/invite-1',
97+
},
98+
});
99+
100+
expect(sendMailMock).toHaveBeenCalledWith({
101+
to: 'owner@example.com',
102+
from: 'from@example.com',
103+
subject: 'Approve University の代表者招待',
104+
html: expect.stringContaining('https://app.example.test/invite/invite-1'),
105+
text: expect.stringContaining('代表者設定を開く: https://app.example.test/invite/invite-1'),
106+
});
107+
});
108+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import nodemailer, { type Transporter } from 'nodemailer';
2+
import type SMTPTransport from 'nodemailer/lib/smtp-transport/index.js';
3+
import type { EmailService, SendEmailParams, SendEmailResult } from './interface.js';
4+
import { resolveSendEmailParams } from './templates.js';
5+
6+
export type SmtpEmailServiceConfig = {
7+
host: string;
8+
port: number;
9+
user: string;
10+
pass: string;
11+
from: string;
12+
secure: boolean;
13+
requireTLS: boolean;
14+
};
15+
16+
export class SmtpEmailService implements EmailService {
17+
private readonly transporter: Transporter<SMTPTransport.SentMessageInfo, SMTPTransport.Options>;
18+
19+
constructor(private readonly config: SmtpEmailServiceConfig) {
20+
this.transporter = nodemailer.createTransport({
21+
host: config.host,
22+
port: config.port,
23+
secure: config.secure,
24+
auth: {
25+
user: config.user,
26+
pass: config.pass,
27+
},
28+
requireTLS: config.requireTLS,
29+
});
30+
}
31+
32+
async sendEmail(params: SendEmailParams): Promise<SendEmailResult> {
33+
const resolved = resolveSendEmailParams(params);
34+
const info = await this.transporter.sendMail({
35+
to: resolved.to,
36+
from: this.config.from,
37+
subject: resolved.subject,
38+
html: resolved.html,
39+
text: resolved.text,
40+
});
41+
42+
return {
43+
success: true,
44+
messageId: info.messageId,
45+
};
46+
}
47+
}

0 commit comments

Comments
 (0)