[Next.js 14] nodemailer로 이메일 보내기 기능 구현 (with 첨부파일 이미지)

강 수정·2024년 1월 24일
0

Next.js : 14.1.0
nodemailer : 6.9.8
tailwindcss : 3.3.0

이 글의 목표는 다음과 같이 설정했습니다.

  • Next.js API Route를 이해하기
  • 프론트엔드와 백엔드 작업 방식 이해하기
  • 이 글만 보고도 이메일 전송 기능 구현 가능하게 하기

전체 작업 과정


1. 기본 UI 작업


// page.tsx

import Contact from './contact/page';

export default function HomePage() {
  return (
    <div>
      <Contact />
    </div>
  );
}

// contact/page.tsx
import EmailForm from '../components/EmailForm';

export default function Contact() {
  return (
    <section>
      <h1>1:1 문의하기</h1>
      <EmailForm />
    </section>
  );
}

// components/EmailForm.tsx
'use client';

import { useState } from 'react';
import Image from 'next/image';

const initialContact = {
  from: '',
  title: '',
  content: '',
};
export default function EmailForm() {
  const [contact, setContact] = useState(initialContact);
  const [file, setFile] = useState<string | undefined>();

  const onChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { value, name } = e.target;
    setContact((prev) => ({
      ...prev,
      [name]: value,
    }));
  };

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log(contact, file); // 수정 예정 코드
  };

  return (
    <form onSubmit={onSubmit}>
      <label>
        제목
        <input
          required
          type="text"
          name="title"
          value={contact.title}
          onChange={onChange}
        />
      </label>
      <label>
        문의 내용
        <textarea
          required
          name="content"
          rows={10}
          value={contact.content}
          onChange={onChange}
        />
      </label>
      <label>
        이메일
        <input
          required
          type="text"
          name="from"
          value={contact.from}
          onChange={onChange}
        />
      </label>
      <label>
        첨부 파일
        <div>
          <div>
            <input
              type="file"
              name="file"
			  accept="image/*"
              onChange={} // 수정 예정 코드 
            />
            {file && (
              <Image
                src={file}
                alt="local file"
                width="70"
                height="70"
              />
            )}
          </div>
        </div>
      </label>
      <button>작성 완료</button>
    </form>
  );
}

코드의 간결성을 위해 tailwindcss 스타일 코드는 다 제거했습니다.

현재 Form 데이터를 전송하는 onSubmit 함수에는 별다른 코드없이 state를 콘솔에 출력하는걸로 해두었습니다. 이 코드는 app 폴더 내 api로 post 요청하는 함수를 호출하는 코드로 수정될 예정입니다.

또한, 첨부파일인 이미지 파일도 state 정의만 해두었을 뿐 onChange함수를 정의하고 있지 않습니다.
아래에서 설명할 예정입니다.



2. nodemailer 함수 설정

app/api폴더 내 email.ts 파일을 만들어서 nodemailer 관련 함수를 설정해주었습니다.

nodemailer 공식문서에 따라 설치해줍니다.

npm i nodemailer

1) transporter 생성

현재 공식문서에 나온 transporter 위 예시를 토대로 우리에게 맞는 코드로 바꿔보겠습니다.

// app/api/email.ts
import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  host: "smtp.gmail.com", 
  port: 465,
  secure: true,
  auth: {
    user: process.env.NEXT_APP_EMAIL,
    pass: process.env.NEXT_APP_PWD,
  },

});

.env파일을 통해 구글계정과 앱 비밀번호를 환경 변수로 설정해봅시다.

// .env
NEXT_APP_EMAIL=자신의구글이메일계정
NEXT_APP_PWD=구글앱비밀번호(16자리)

2) 구글 앱 비밀번호 발급

구글 계정에 들어가서 앱 비밀번호라고 치거나 왼 쪽에 보안 탭으로 갑니다.

해당 화면이 나옵니다. 간단하게 앱 이름으로 프로젝트 이름을 써줍니다.

만들기 버튼을 누르면 앱 비밀번호를 확인할 수 있습니다. 16자리의 앱 비밀번호가 생성되는데 한 번 발급받으면 다시 알 수 없으므로 어딘가에 잘 기록해 두시는게 좋습니다.

3) sendMail 함수 return

transporter을 생성했으니 transporter의 sendMail을 return해야 합니다.
mailOptions을 설정하여 sendMail 매개변수로 넣어줍니다.

공식문서에 나온 mailOpions입니다.
이제 이 코드를 우리에게 맞는 코드로 바꿔봅시다.
(더 많은 mailOptions은 해당 공식문서 페이지를 확인하면 됩니다.)

// app/api/email.ts
// ...
export type ContactType = {
	from: string;
	title: string;
	content: string;
	file?: string;
};

type MailOptionType = {
	to: string;
	from: string;
	subject: string;
	attachments?: Attachment[];
	html: string;
};

export function sendEmail({ from, title, content, file }: ContactType) {
	const mailOptions: MailOptionType = {
		to: process.env.NEXT_APP_EMAIL || '',
		from,
		subject: `[1:1 문의] ${title}`,
		attachments: [
			{
				path: file,
			},
		],
		html: `
    		<h1>${title}</h1>
    		<div>${content}</div>
    		</br>
    		<p>보낸사람 : ${from}</p>
    		`,
		};
	return transporter.sendMail(mailOptions);
}

저희는 첨부파일도 이미지로 추가할 예정이라, attachmets도 넣어주었습니다.



3. api 요청 함수 설정

nodemailer 관련 함수를 email.ts에서 잘 설정해두었으니, 클라이언트단에서 API Route에 이메일 전송을 위한 요청을 보내야 합니다.

클라이언트에서 서버로 api 요청하는 함수이기 때문에 클라이언트단에서 onSubmit함수 내에서 바로 실행해도 되지만, 비즈니스 로직과 ui 로직을 분리하기 위해 별개의 파일로 만들어줍니다.

// api/contact.ts
import { ContactType } from './email';

export async function sendContactEmail(emailForm: ContactType) {
	const response = await fetch('/api/contact', {
		method: 'POST',
		body: JSON.stringify(emailForm),
		headers: {
			'Content-Type': 'application/json',
		},
	});

	const data = await response.json();

	if (!response.ok) {
		throw new Error(data.message || '서버 요청에 실패함');
	}

	return data;
}


4. 클라이언트에서 위 함수 호출

'use client';
...
import { sendContactEmail } from '../api/contact';
...
  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    sendContactEmail({ ...contact, file }); // 호출 
  };

...

클라이언트 컴포넌트에서 api 요청 함수인 sendContactEmail에 props로 from, title, content, file을 넘겨줍니다.

💡 첨부파일 이미지 삽입

// EmailForm.tsx
...
const [file, setFile] = useState<string | undefined>();
...
const onSelectFile = useCallback(
  (e: React.ChangeEvent<HTMLInputElement>) => {
    if (!e.target.files) {
      return;
    }
    if (e.target.files[0].type) {
      let reader = new FileReader();
      reader.readAsDataURL(e.target.files[0]);
      reader.onloadend = () => {
        const base64 = reader.result;
        if (base64) {
          let base64Sub = base64.toString();
          setFile(base64Sub);
        }
      };
    } else {
      alert('파일 형식이 올바르지 않습니다.');
    }
  }, []);

file state을 콘솔에 찍어보면 다음과 같이 나타납니다.

nodemailer함수에 첨부파일을 넣기 위해 해당 file state을 url(타입: string)로 만들어주었습니다. 보통 input type이 file인 경우에 input value를 File타입으로 지정하지만, nodemailer의 mailOptions에서 attachments의 path는 url경로가 들어가기 때문에 타입을 string으로 지정했습니다.

파일을 Base64 인코딩한 데이터 URL입니다. 이 데이터 URL은 이미지를 나타내는 URL 형식의 문자열입니다. 이 문자열은 Base64로 인코딩된 이미지 데이터를 포함하며, 브라우저에서 직접 사용할 수 있습니다.

(path에 url이나 파일 경로가 들어가야 하지만, 저희는 사용자가 추가한 첨부파일 이미지 url값을 얻어와야 해서 삽질을 하다가 해결했습니다. 😅)

// email.ts
...
const mailOptions: MailOptionType = {
		to: process.env.NEXT_APP_EMAIL || '',
		from,
		subject: `[1:1 문의] ${title}`,
		attachments: [
			{
				path: file, // 파일 경로나 url 
			},
		],
		html: `
    		<h1>${title}</h1>
    		<div>${content}</div>
    		</br>
    		<p>보낸사람 : ${from}</p>
    		`,
};
...


5. API Routes 설정

이 API Route 는 next.js의 자체 서버를 사용해서 다른 서버가 필요없이 서버리스하게 API를 간단하게 만들 수 있다는 장점이 있습니다. 이를 잘 활용하면 개발을 하다가 Backend API 가 필요한 시점에서 백엔드에게 API 요청을 하거나, 따로 API 서버를 생성할 필요가 없이 프론트단에서 처리를 할 수가 있게 됩니다.

현재 Next.js 14버전을 사용하고 있기 때문에 pages/api/*가 아니라 app/api/*에 api를 만들어보겠습니다. 아직 공식문서에서는 여전히 pages 경로를 이용하여 설명하고 있어서 루트 설정하는데 삽질 좀 하다가 올바른 경로를 찾았습니다.

라우팅하듯이 원하는 이름의 폴더를 만들고 그 안에 route.ts를 설정하면 됩니다.

// app/api/contact/route.ts
import { sendEmail } from '@/app/api/email';

export async function POST(req: Request) {
  const body = await req.json();
  return sendEmail(body)
    .then(
      () =>
        new Response(JSON.stringify({ message: '메일을 성공적으로 보냈음' }), {
          status: 200,
        })
    )
    .catch(() => {
      return new Response(
        JSON.stringify({ message: '메일 전송에 실패함' }),
        {
          status: 500,
        }
      );
    });
}

nodemailer는 node서버에서 메일을 보낼 수 있는 메일 전송 모듈이라 서버에서만 이용할 수 있습니다.
서버단에서 nodemailer 함수인 sendEmail을 이용해서 요청받은대로 이메일을 전송하면 됩니다!



6. 결과

화면이 잘 나오는 것을 확인할 수 있습니다! !

깃허브에 오시면 velog 폴더에서 전체 소스 코드를 확인할 수 있습니다.

profile
주니어 개발자 깡수 개발일지

0개의 댓글