[Nestjs] Nodemailer 로 메일 보내기
여러분이 웹서비스를 이용하다보면 실생활에서
회원 가입할때 사용된 이메일로 no-reply 라는 알림서비스를 받은 경험이 있을 것이다.
회원가입을 하게되면, 이메일로 회원가입을 했다고 알림을 하듯이...
또는 결제완료/ 예약완료 등 서비스로부터의 이벤트를 요청했을 때도 이메일로 알림을 받았듯이 말이다.
알림용도로 이메일을 활용해서 유저에게 웹서비스를 제공한다.
그래서 이러한 알림처리 기능을 그동안 해본적이 없는데
이 호기심을 직접 탐구해보면서 어떤 로직으로 어떤 알고리즘으로 구현하는지를 알고 싶었다.
그요즘은 오픈소스가 잘되어있어서 구글링을 통해서, 오픈소스를 통해서 쉽게 구현할 수 있었다.
필자는 Node.js 환경에서 작업했으며, Nodemailer를 이용했고, Gmail계정으로 이메일 인증하는데 있어서
인증번호를 알려주는 서비스 로직을 만들기로했다.
인증번호 이메일 알림 서비스 이후에는 이메일을 활용해서
회원가입, 비밀번호 변경 이라는 이벤트가 발생할 때 이메일을 통해서 환영메시지를 나타내거나
유효시간동안 비밀번호를 바꿀수 있는 링크를 전송하는 서비스를 만들 예정이다.
이번에는 Nodemailer 를 처음 사용해보는데 어떻게 사용했고 발생한 문제해결 과정들을 기록했다.
Node-Mailer를 한번 써보자.
nodemailer 관련 패키지 설치
$ npm install --save @nestjs-modules/mailer nodemailer
$ npm install --save handlebars
$ npm install --save-dev @types/nodemailer
email 내용 작성을 위한 패키지 설치
npm install --save handlebars
EmailModule 모듈을 만든 후 만든 모듈안에 NodemailerModule 을 임포트
- nest-cli 로 email 폴더안에 모듈/서비스/컨트롤러를 생성
$ nest g mo email
$ nest g s email
$ nest g co email
Nodemailer OAuth2.0 사용하기
1. GCP에서 프로젝트를 생성한다.
2. Gmail API 생성하기
- API 및 서비스 > 라이브러리 로 이동한다.
- gmail을 검색하면 'Gmail API'를 선택한다.
3. API 및 서비스 > OAuth2.0 동의화면 설정 사용자 인증정보 Client-ID, Client-Secret 값을 받는다.
- 외부선택
- 앱정보 입력
- 테스트 사용자 추가
필자의 경우에는 본인의 이메일로 했다.
- API 및 서비스 > 사용자 인증정보 > 사용자 인증정보 만들기 클릭 > OAuth 클라이언트 ID 클릭
4. OAuth2.0 Playground 에서 테스트하기
- 아래사진처럼 OAuth Credentials를 추가하기
- Step 1 . Select & authorize APIs 에 입력창에 'https://mail.google.com' 넣기
계정을 클릭하면 'Google에서 확인하지 않은 앱' 이라고 뜬다. '계속'을 클릭하여 앱권한을 허용하여 API인증이 완료된다.
다시 Step2로 돌아가면 AccessToken, RefreshToken을 발급했음을 확인할 수 있다. RefreshToken만 따로 복사한다.
- .env 파일
GOOGLE_OAUTH_USER={{ 프로젝트 개발자 계정 }}
GOOGLE_OAUTH_NODEMAILER_CLIENT_ID={{ 프로젝트 client-ID }}
GOOGLE_OAUTH_NODEMAILER_CLIENT_SECRET={{ 프로젝트 client-secret }}
GOOGLE_OAUTH_NODEMAILER_REFRESH_TOKEN={{ Playground에서 얻은 refreshToken }}
- EmailModule.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { EmailController } from './email.controller';
import { EmailService } from './email.service';
@Module({
imports: [
ConfigModule,
MailerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
transport: {
service: 'gmail',
host: 'smtp.gmail.com',
port: 465, // OAuth2 Connection
secure: true,
auth: {
type: 'OAuth2',
user: configService.get<string>('MAILER_USER'),
clientId: configService.get<string>(
'GOOGLE_OAUTH_NODEMAILER_CLIENT_ID',
),
clientSecret: configService.get<string>(
'GOOGLE_OAUTH_NODEMAILER_CLIENT_SECRET',
),
refreshToken: configService.get<string>(
'GOOGLE_OAUTH_NODEMAILER_REFRESH_TOKEN',
),
},
},
defaults: {
from: '"No-Reply" <no-reply@localhost>',
},
preview: false,
template: {
dir: __dirname + '/templates',
adapter: new HandlebarsAdapter(),
options: {
strict: true,
},
},
}),
}),
],
controllers: [EmailController],
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}
- EmailService
// email.service.ts
import { MailerService } from '@nestjs-modules/mailer';
import { Injectable, UseFilters } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpExceptionFilter } from '../error/http-exception.filter';
@UseFilters(new HttpExceptionFilter())
@Injectable()
export class EmailService {
constructor(
private readonly configService: ConfigService,
private readonly mailerService: MailerService,
) {}
async sendHello(): Promise<boolean> {
await this.mailerService
.sendMail({
to: 'ekk12mv2@gmail.com',
from: 'noreply@gmail.com',
subject: 'Hello',
text: 'hello World',
html: `<b>Hello World</b>`,
})
.then((result) => {
console.log(result);
})
.catch((error) => {
console.error(error);
throw error;
});
return true;
}
}
- EmailController
// email.controller.ts
import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EmailService } from './email.service';
import { ApiOperation } from '@nestjs/swagger';
@Controller('email')
export class EmailController {
constructor(
private readonly emailService: EmailService,
private readonly configService: ConfigService,
) {}
@ApiOperation({
summary: '메일전송 테스트',
})
@Get('test')
async testEmail() {
return await this.emailService.sendHello();
}
}
- 결과
- 한번 api를 호출하면 실제 메일 전송자 계정에서는 5분동안 gmail 서비스를 이용할 수 없다는 점 유의하자.
- refreshToken 유효기간이 만료되면 unauthorized_client 라는 에러가 발생한다.
- google Oauth2.0 Playground를 통해서 액세스토큰과 리프래시토큰을 사용하는데 refreshToken 이 만료되면 수동적으로 발급받아서 갱신해야된다는 번거로움이 있다. 그러면 refreshToken을 자동으로 발급하려면 어떻게 해결해야될까?
트러블슈팅1 : Error: Invalid login: 535-5.7.8 Username and Password not accepted.
Nodemailer OAuth 가 아닌 Single Connection으로 했을 때 발생한 에러이다.
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { EmailController } from './email.controller';
import { EmailService } from './email.service';
@Module({
imports: [
ConfigModule,
MailerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
transport: {
host: 'smtp.gmail.com',
port: 587, // Single Connection
secure: false, // Use TLS
auth: {
user: configService.get<string>('MAILER_USER'),
pass: configService.get<string>('MAILER_PWD'),
},
},
defaults: {
from: '"No-Reply" <no-reply@localhost>',
},
preview: false, // true로 하면 메일 전송을 요청할때마다 보낸 내용을 브라우저에서 확인할 수 있다.
template: {
dir: __dirname + '/templates',
adapter: new HandlebarsAdapter(), // Handlebars 이메일 내용 포맷을 담당하는 객체를 넣는다.
options: {
strict: true,
},
},
}),
}),
],
controllers: [EmailController],
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}
위의 EmailModule에서 MailerModule의 auth 에 설정된 값인
MAILER_USER 와 MAILER_PWD 키에 어떤 값을 매핑을 해야될지 몰라서
내 이메일 계정과 비밀번호를 넣었다.
그러더니 "Error: Invalid login: 535-5.7.8 Username and Password not accepted." 에러 메시지를 받았다.
Learn more 에 해당하는 링크에 들어가보니.. 원인은 3가지 중 하나이다.
1. 잘못된 비밀번호
-> 내 계정비밀번호를 올바르게 했으니. 원인은 아닌것 같다.
-> 수많은 삽질끝에 이게 원인이었다 🤯
2. 2단계 보안 인증을 설정했을 경우
-> 보안인증을 설정하지 않았다. (설정했을경우에는 해결방안은 밑에 있다)
3. IMAP 이 켜져있는지 확인
물론 아래 구글 고객센터 링크에 친절하게 표시되어있다.
IMAP 설정하는 방법은 바로밑에 있는 접은글 을 참고하면 된다.
[ IMAP 설정하기 ]
1. Gmail의 우측상단 '설정' > '모든 설정 보기' 클릭
2. 전달 및 POP/IMAP 클릭
3. IMAP액세스의 'IMAP 사용' 선택
4. 구글계정이 2단계 인증을 활성화 시킨 경우 > 앱 비밀번호를 생성해야한다.
- 필자는 2단계 인증을 활성화시키지 않아서 에러원인에 해당하지 않았다.
- 2단계인증을 설정후 비밀번호값을 매핑했더니 정상적으로 메일이 전송됐다.
[ 앱비밀번호 생성 ]
- 2단계인증을 활성화시켜서 해봤다.
1. 구글계정 프로필 선택 > 구글계정관리 클릭
2. 보안 메뉴로 이동
3. 앱비밀번호 만들기
- '기타(맞춤 이름') 으로 설정하고, 본인이 직접입력하여 임의값으로 한다.
기기용 앱 비밀번호를 패스워드로 설정 한다.
이 비밀번호를 복사하여 .env 파일의 MAILER_PWD 값에 매핑하면된다.
[참고]
[ Support Google email 에러 관련 가이드라인 ]
트러블슈팅 2: Error: unauthorized_client: Unauthorized
Google OAuth Playground 로부터 발급받은 refresh토큰이 만료되면 발생하는 에러이다.
아니면 토큰값, 유저값, 클라이언트 id, 클라이언트 secret 값이 정확값으로 매핑이 잘됐는지 확인해보는게 좋다.
2LO 와 3LO 방식
2LO와 3LO가 무엇인지 몰라서 처음에는 이해가 안갔다.
자료조사와 분석결과 필자가 작성한 Nodemailer OAuth2 방식은 3LO 이다.
2LO 방식을 사용해서 권한요청 및 사용자 동의없이 바로 데이터 액세스할 수 있단 장점이 있다.
gsuite는 '도메인 전체 권한 위임'으로 구글워크스페이스 도메인 사용자를 대신해서 데이터에 액세스하도록 서비스 계정을 승인한다.
3LO (일반계정): 앱 -> 사용자 동의 -> 데이터획득
2LO (gsuite계정): 앱 -> 데이터획득
정리하자면 , gmail을 보내는데 3단계를 거쳐야할 것을 2단계로 줄이는 것이다.
2LO방식을 사용하게되면, 사용자의 허가를 요청할 필요없으므로 accessToken, refreshToken도 필요없다 는 거다.
위임하는 방법은 아래 링크에 있지만, gsuite는 유료플랜이므로 하지 않기로했다.
- 2LO 인증구성
nodemailer는 액세스토큰을 생성하기 위해 사용자가 서비스 계정을 사용하도록 허용해준다.
auth 옵션에서 필수로 넣어야되는 값은 'type', 'user', 'serviceClient' 이다.
auth: {
type: 'OAuth2', // (required)
user: {{ 사용자 이메일주소 }}, // (required)
serviceClient: {{ 서비스 클라이언트 ID }}, // (required)
privateKey: {{ service key파일의 private_key 영역에 해당함 }}
}
- 3LO 인증구성
3LO 인증구성 는 2LO보다 넣어야할 값이 많다.
auth: {
type: 'OAuth2', // (required)
user: {{ 유저이메일 }}, // (required)
clientId: {{ 프로젝트 앱 clientId }}, // (required)
clientSecret: {{ 프로젝트 앱 clientSecret }}, // (required)
refreshToken: {{ Google OAuth Playground에서 얻은 리프래시토큰값 }},
accessToken: {{ Google OAuth Playground에서 얻은 액세스토큰값 }}, // refreshToken 명시하지 않으면 required
expires: {{ accessToken 만료시간(숫자타입) }},
accessUrl: {{ '새로운 access 토큰 요청하는 엔드포인트 URL, gmail로 default 되어있음' }}
}
자세한 정보는 Nodemailer 공식 다큐먼트에있다!
나는 다큐먼트 읽고 바로 이해하는데 시간이 걸리는 편인데, 노드메일러는 그래도 나름 친절하다.
[참고]
[nestjs 공식 다큐먼트]
[Nodemailer x Gmail Oauth2.0 사용하기]
[ Nodemailer - 일반 gmail계정으로 2LO authentication을 구성했을 때 unauthorized_client 에러 발생 이유]