Backend/꾸준히 TIL

[Nestjs] Nodemailer 로 메일 보내기

개발하는 후딘 2023. 1. 21. 22:10
728x90
반응형

여러분이 웹서비스를 이용하다보면 실생활에서

회원 가입할때 사용된 이메일로 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 값에 매핑하면된다.

 

[참고]

 

nodemailer로 Gmail 보내기(+ 보안문제 해결)

nodemailer로 이메일 보내기 nodeJS에서 이메일을 보내는 용도로 nodemailer라는 라이브러리를 사용할 수 있다. nodemailer 설치 nodemailer로 이메일 전송 Gmail 보안 관련 에러 메세지 google 계정이 2단계 인증

www.cckn.dev

 

 

[ Support Google email 에러 관련 가이드라인 ]

 

다른 이메일 플랫폼을 통해 Gmail 확인하기 - Gmail 고객센터

도움이 되었나요? 어떻게 하면 개선할 수 있을까요? 예아니요

support.google.com


트러블슈팅 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 공식 다큐먼트에있다!

나는 다큐먼트 읽고 바로 이해하는데 시간이 걸리는 편인데, 노드메일러는 그래도 나름 친절하다.

 

OAuth2 :: Nodemailer

OAuth2 OAuth2 allows your application to store and use authentication tokens instead of actual login credentials. This is great for security as tokens or valid only for specific actions and can be easily revoked thus, once stolen, can’t to as much harm a

nodemailer.com

 


[참고]

더보기
728x90
반응형