이 글을 읽기 전에, 이전 글을 읽어보는 것을 권한다. 이전 글에서는 계기와 설정 과정만을 생각했다면, 이번 글부터 본격적으로 메일 클라이언트를 개발해 본다. 중점적으로 볼 내용은 엔드포인트 부분으로, 실질적인 메일 전송과 수신을 담당한다. 이 글은 웹메일이 실제 서비스에서 동작하는 원리에 대한 기초적 아이디어를 설명하고 있다. 구현해야 할 분량이 많지 않지만, 프로젝트의 엔드포인트인 만큼 중요한 위치에 있음에 유의하면서 코드를 작성해 본다.
Step 4.
엔드포인트: 사용자 로그인
로그인을 위해 'express-session'과 'memorystore' 라이브러리를 설치해 준다. 메모리에 크나큰 부담을 줄 정도로 세션을 발급할 것은 아니기에, MySQL이나 MongoDB와 같은 데이터베이스를 사용하지 않게끔 메모리에 세션 데이터를 저장해도 문제가 없을 것이라 본다.
{
"name": "everyday",
"version": "1.0.0",
"description": "",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node ./main.js",
"start:dev": "nodemon --watch ./ --exec \"npm start\""
},
"author": "player_decuple",
"license": "MIT",
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-session": "^1.18.0",
"mailpop3": "^0.1.0",
"memorystore": "^1.6.7",
"nodemailer": "^6.9.14"
}
}
적용한 라이브러리들을 main.js에서 사용해 볼 때는 다음과 같이 사용한다. 옵션 객체의 secret은 세션 암호화 시의 salt로 사용되기에 아무런 문자열이나 사용해도 괜찮고, resave 옵션은 요청이 들어올 때마다 무조건 세션을 다시 저장하는 옵션이지만 데이터의 충돌을 방지하기 위해 비활성화한다. saveUninitalized 옵션은 데이터가 없어 비어 있는 세션을 저장하는 옵션이다. 마찬가지로 필요하지 않고 효율성만 낮추어 좋을 것이 없으므로 비활성화한다.
const session = require('express-session');
const MemStore = require('memorystore')(session);
const sessionOption = {
secret: Math.random().toString().split(".").pop(),
resave: false,
saveUninitialized: true,
store: new MemStore({ checkPeriod: maxAgeSec * 1000 })
};
// ...
app.use(session(sessionOption));
백엔드나 사용자 데이터를 저장하지 않고 POP3 서버에 로그인 정보를 넘겨 그 응답으로 로그인 여부를 판단하게 된다. 이전 글의 플로우 차트에서 이 부분을 제작함으로써 사용자 로그인을 처리할 수 있다.
일단, POST /login으로 요청을 전송하면 받은 요청을 통해 POP3 서버에 로그인 시도해보아야 한다. mailpop3 라이브러리가 POP3Client.login(username, password) 메서드를 통해 POP3 서버 로그인을 지원하므로 받은 사용자 이름(아이디)과 비밀번호 데이터를 그대로 파라미터로 사용하면 된다.
POST 요청은 요청 내의 Body를 통해 데이터를 전송받을 수 있다. RESTful API와의 통신에서는 보통 Body를 JSON 형식으로 사용하는데, JSON 형식으로 전송된 Body의 여러 속성들에 편리하게 접근하기 위해, Express.js 내에 존재하는 파서를 사용하도록 만들어 준다.
// main.js
app.use(express.json());
이제 라우터 등지에서 Body가 자동으로 파싱 되어 req.body.username과 같이 각 속성에 객체처럼 바로 접근할 수 있다.
const express = require('express');
const router = express.Router();
router.post("/login", async (req, res) => {
const body = req.body;
const username = req.username;
const password = req.password;
console.log(username, password);
});
mailpop3는 POP3 서버 연결과 자체 이벤트 처리를 모두 이벤트 리스너 형식으로만 지원하고 있다. 그 말인즉, 필자가 원하는 플로우를 구현하기 위해 여러 제약이 존재한다는 점이다. 예를 들어, 로그인 후에 즉시 메일을 전송하는 플로우 A가 있고, 로그인 후 즉시 메일 리스트를 불러오는 플로우 B가 있을 때, 로그인 이벤트 리스너 내부에서 각 플로우에 대한 코드를 직접 작성해 주어야 한다는 불편함이 있었다.
따라서, mailpop3을 Promise로 동작하게 만들기 위해 아래와 같이 코드를 작성한다.
// core/pop3.client.js
const Core = require("mailpop3");
class POP3Client {
client;
host; port; useSSL;
constructor() {
this.host = process.env.POP3_HOST;
this.port = process.env.POP3_PORT;
this.useSSL = process.env.POP3_TLS_SSL == "true";
}
connect() {
const { host, port, useSSL } = this;
this.client = new Core(port, host, {
tlserrs: false,
enabletls: useSSL,
debug: false
});
return new Promise((res, rej) => {
this.client.on("connect", res);
this.client.on("error", rej);
});
}
disconnect() {
this.client.quit();
}
login(username, password) {
return new Promise((res, rej) => {
this.client.on("login", (st, raw) => (st ? res : rej)(raw));
this.client.login(username, password);
});
}
}
module.exports = POP3Client;
connect 메서드와 login 메서드는 일회성으로 실행되는 메서드들이기에, 이벤트 리스너를 on으로 등록하여도 큰 상관은 없다. 하지만 LIST나 RETR과 같이 여러 번 실행되는 메서드들은 이벤트 리스너를 off 처리해 줄 필요가 있다.
Reject 처리된 Promise 객체에 await 키워드를 사용할 경우, Error가 Throw 된다는 점을 이용해서, try~catch로 로그인 성공과 실패 여부를 구별해 낼 수 있다.
// route/endpoint.route.js
const express = require('express');
const POP3Client = require('../core/pop3.client');
const router = express.Router();
function sendResponse(res, st, ...msg) {
res.status(st).send({
statusCode: st,
message: msg
});
}
router.post("/login", async (req, res) => {
const body = req.body;
const username = body.username;
const password = body.password;
if (username == null || password == null) {
return sendResponse(res, 400, "Bad Request");
}
const client = new POP3Client();
await client.connect();
try {
await client.login(username, password);
sendResponse(res, 200, "OK");
} catch (e) {
client.disconnect(); // 로그인에 실패한 클라이언트가 메모리에 점유되는 것을 방지한다
sendResponse(res, 401, "Invalid username or password");
}
});
module.exports = router;
이후의 요청에서 사용자가 누구인지 확인할 수 있도록 세션에 사용자 이름(이메일)을 저장한다. 또한, 사용자마다 다른 클라이언트를 사용할 수 있도록 Map을 활용하여 사용자 이름을 키로, 클라이언트를 값으로 저장한다. 따라서 앞으로의 요청에서는 세션 내의 사용자 이름을 통해 클라이언트를 가져와 원하는 명령을 실행할 수 있다.
const clientMap = new Map();
// ...
router.post("/login", async (req, res) => {
// ...
try {
await client.login(username, password);
if (clientMap.has(username))
clientMap.get(username).disconnect();
clientMap.set(username, client);
req.session.username = username;
sendResponse(res, 200, "OK");
} catch (e) {
client.disconnect();
sendResponse(res, 401, "Invalid username or password");
}
});
module.exports = router;
사용자 이름으로 연결된 클라이언트가 이미 존재할 경우 존재하던 클라이언트의 연결을 종료한다. 여기까지만 한다면 사용자 로그인이 완료된다.
Step 5.
엔드포인트: 이메일 리스트
로그인 후 이메일 목록을 확인할 수 있는 메인 페이지에 접근하면 엔드포인트 호출을 통해 메일들을 확인할 수 있어야 한다. 이전 글에서는 메일 목록을 아래와 같은 예제를 통해 불러온 것을 확인할 수 있다.
client.list();
client.on("list", function (st, cnt, no, d, raw) {
console.log(raw);
});
/*
1 23860
2 30638
3 8559992
4 5656932
...
*/
이 과정 또한 async-await 키워드를 사용하기 위해 Promise 형식으로 변형할 것이다. 그리고 사용자 로그인 이후에도 목록을 불러올 필요가 있기에 하나의 메서드로 정의할 것이다. 이메일의 내용을 추출하기 위해 'mailparser' 라이브러리를 npm을 통해 설치해 준다.
이메일 리스트를 불러오는 .list(startIdx, size) 메서드는 시작 인덱스와 메일의 개수를 받아 제목, 첨부파일 여부 등의 간략한 정보를 반환한다. 이때, 시작 인덱스는 오름차순이다. 즉, 시작 인덱스가 0인 경우 제일 최근의 메일 정보부터 가져오게 된다.
// core/pop3.client.js
const simpleParser = require('mailparser').simpleParser;
class POP3Client {
// ...
async list(startIdx, size) {
const count = await new Promise((res, rej) => {
const $this = this;
this.client.on("list", function listListener(st, cnt, no, data, raw) {
(st !== false ? res : rej)(cnt);
$this.client.off("list", listListener);
});
this.client.list();
});
const data = [];
startIdx = count - startIdx;
for (let i = startIdx; i >= Math.max(startIdx - size, 1); i--) {
data.push(await this.retr(i));
}
return data;
}
async retr(idx) {
return new Promise((res, rej) => {
const $this = this;
this.client.on("retr", async function retrListener(st, no, data, raw) {
const parsed = await simpleParser(data);
parsed.idx = idx;
(st !== false ? res : rej)(parsed);
$this.client.off("retr", retrListener);
});
this.client.retr(idx);
});
}
// ...
.retr(idx) 함수는 이전 글의 예제와 같이 'idx' 인덱스에 해당하는 메일의 상세 정보를 가져와 파싱한다. 이후 메일 상세 정보 페이지에 접근하기 쉽도록 idx를 파싱한 데이터에 삽입한다.
이후 엔드포인트에서 list 함수를 호출하고 사용자에게 표시한다. 이 엔드포인트는 로그인한 사용자 이외에는 접근할 수 없어야 하므로, 'use'를 통해 미들웨어를 추가해 주는 작업도 필요하다.
// route/endpoint.route.js
// ...
function checkUserPermission(req, res, next) {
if (req.session.username == null) {
sendResponse(res, 401, "Unauthorized");
return;
}
next();
}
// ...
router.use("/mails", checkUserPermission);
router.get("/mails", async (req, res) => {
try {
const client = clientMap.get(req.session.username);
const startIdx = (req.start ?? 0) * 1;
let size = (req.size ?? 20) * 1;
if (size <= 0 || startIdx < 0 || isNaN(startIdx) || isNaN(size))
throw new Error();
if (size > 40)
size = 40;
const list = await client.list(startIdx, size);
const data = list.map((mail) => ({
idx: mail.idx,
subject: mail.subject,
date: mail.date,
from: mail.from,
hasAttachment: mail.attachment.length > 0
}));
res.status(200).send(data);
} catch (e) {
sendResponse(res, 400, "Bad Request");
}
});
이렇게 하면 로그인 이후 GET /endpoints/mail을 요청하여 메일 리스트를 가져올 수 있다.
Step 6.
엔드포인트: 이메일 상세
이미 이전 단계에서 .retr(idx) 메서드를 만들어두었기에 단순히 RETR 명령을 실행하는 엔드포인트만 작성해도 된다.
// route/endpoint.route.js
// ...
router.use("/mails/:mail_id", checkUserPermission);
router.get("/mails/:mail_id", async (req, res) => {
try {
const client = clientMap.get(req.session.username);
const mailIdx = req.params["mail_id"] * 1;
if (isNaN(mailIdx))
throw new Error();
res.status(200).send(await client.retr(mailIdx));
} catch (e) {
console.error(e);
sendResponse(res, 400, "Bad Request");
}
});
// ...
하지만 .retr(idx) 메서드가 올바르지 않은 idx를 받으면 메일 정보를 가져올 수 없어 simpleParser 부분에서 오류가 발생한다. .retr(idx) 메서드 하나만으로는 메일함 크기 등을 가져올 수 없으므로, 오류가 발생하면 Reject 함수를 실행하도록 만들어야 한다.
// ...
async retr(idx) {
return new Promise((res, rej) => {
const $this = this;
this.client.on("retr", async function retrListener(st, no, data, raw) {
try {
const parsed = await simpleParser(data);
parsed.idx = idx;
(st !== false ? res : rej)(parsed);
$this.client.off("retr", retrListener);
} catch (e) {
rej();
}
});
this.client.retr(idx);
});
}
// ...
이 부분까지 완료하고 나면, 메일을 성공적으로 받아오는 것을 확인할 수 있다.
마치며
mailpop3와 mailparser를 통해 메일의 내용을 불러오고 파싱하는 과정이었다. 메일 수정 기능은 거의 사용하지 않고, 메일 삭제 기능은 추후에 구현할 예정이기 때문에 메일 가져오기와 메일 보내기를 주요 기능으로 보고 개발하고자 한다. 다음 글에서는 nodemailer를 사용한 메일 발신에 대한 내용을 확인할 수 있을 것이다. 이번에도 긴 글 읽어 주시어 진심으로 감사한 말씀을 전한다.
'개발일지 > 웹' 카테고리의 다른 글
텍스트 에디터로 메일 전송 페이지 만들기: 웹메일 클라이언트 개발하기 6 (0) | 2024.07.28 |
---|---|
웹메일들은 어떻게 메일을 보여주는가: 웹메일 클라이언트 개발하기 5 (0) | 2024.07.23 |
로그인 페이지 구현해보기: 웹메일 클라이언트 개발하기 4 (0) | 2024.07.20 |
웹 우체국으로 편지 보내는 방법: 웹메일 클라이언트 개발하기 3 (0) | 2024.07.19 |
내 도메인 주소에서 보내는 메일: 웹메일 클라이언트 개발하기 1 (0) | 2024.07.14 |