웹메일 클라이언트 개발일지가 벌써 다섯 편이나 작성되었다. 필자로서도, 기나긴 내용의 끝이 다가오기에 더 열정적으로 개발하고 있다. 그중에서도 이번 글은 웹메일 서비스에서 메일 리스트와 메일 상세, 즉 메일 데이터를 가공하여 사용자에게 보여주는 페이지를 작성하는 내용을 다룬다. 이 글에서 요하는 '사용자의 로그인 여부 확인' 등의 내용은 이전 글에서, 메일 데이터를 요청받고 응답하는 백엔드에 관한 내용은 이 글에서 확인해 볼 수 있다. 만약 당신이 필자가 개발하는 이 프로젝트에 관해 관심이 있거나, SMTP 및 POP3를 활용한 클라이언트 개발에 참고하려는 경우 '웹메일 클라이언트 개발하기' 태그 페이지에 방문해 보는 것도 좋다.
이 글은 하나의 개발일지로서, 개발을 위해 필요했던 상식, 지식, 코드 등을 정리해 놓은 글이다. 강의 목적의 글이 아니기에 HTML, CSS, JavaScript와 같은 기초적 프론트엔드 웹 지식과 Node.js, Express.js와 같은 기초적 JavaScript 기반 백엔드 웹 지식을 요한다. 따라서, 개발자가 아니거나, 프로그래밍에 관심이 없는 사람이라면 이 글이 당신의 의문점이나 관심사에 큰 도움을 주지 못할 수도 있다. 당신이 만약 기술은 있지만 아이디어가 없는 사람이거나, 웹 기술이나 이메일 관련 기술에 대해 학습이 필요한 사람이라면 이 글이 도움이 될 수도 있다.
Step 12.
프론트엔드: 메일 리스트
이전 글에서 작성했던 $ELEMENTS 객체 상수를 기억하고 있는가? 이 상수는 Node.querySelector(selector) 메서드의 사용 횟수를 줄이고 코드 가독성 향상을 위해 작성한 것으로, DOM 개체의 기본적인 정보와 해당 개체에 할당되어야 할 이벤트 리스너들을 전부 담고 있다.
const $ELEMENTS = {};
// ...
Object.assign($ELEMENTS, {
"login.username": {
element: document.querySelector("#login-id")
},
"login.password": {
element: document.querySelector("#login-pass")
},
"login.submit_button": {
element: document.querySelector("#login-btn"),
eventListeners: [
{
event: "click",
eventListener: Client.loginPage.submit
}
]
},
});
하지만, 필자가 지금 개발하려는 페이지는 페이지가 로드되는 즉시 실행되어야 한다. 이를 위해서는 페이지 로드 시 실행되는 이벤트 리스너를 사용해야 할 것이다. 예를 들어서 DOMContentLoaded 이벤트가 있겠다. 이미 필자가 작성한 클라이언트 스크립트에서도 해당 이벤트 리스너를 사용한 모습을 볼 수 있다.
// everyday.client.js
// ...
document.addEventListener("DOMContentLoaded", () => {
Client.init();
});
하지만, DOMContentLoaded 이벤트에 대해 리스너를 작성하기에는 문제가 한 개 있다. 이는 DOMContentLoaded가 완료되어 이벤트가 발생한 뒤에는 Node.addEventListener(event, callback) 메서드를 통해 새 이벤트 리스너를 할당하더라도 동작하지 않는다. 즉, 일회성 이벤트이다.
그렇다고, $ELEMENTS 객체에 할당하지 않고 Client를 초기화하는 이벤트 리스너에 코드를 추가하자니, $ELEMENT 상수를 정의한 의의를 해치는 것이라는 느낌이 든다. 그래서 필자가 생각한 방법은, $ELEMENTS 내 요소의 event 값이 특정 값일 때, eventListener를 노드에 할당하지 않고 그 자리에서 즉시 호출하는 것이다.
// assets/scripts/everyday.client.js
initTable() {
Object.assign($ELEMENTS, {
// ...
"mails.load": {
element: null,
eventListeners: [
{
event: "initialize",
eventListener: Client.mailListPage.load
}
]
}
});
},
// ...
listen() {
for (const v of Object.values($ELEMENTS)) {
for (const listener of (v.eventListeners ?? [])) {
if (listener.event == "initialize")
listener.eventListener();
else
v.element.addEventListener(listener.event, listener.eventListener);
}
}
}
// ...
다만, 이렇게 작성한다면 요청한 페이지에 대한 이벤트 리스너뿐만 아니라 event가 "initialize"인 다른 모든 이벤트 리스너가 호출될 것이다. 즉, 필요하지 않은 기능이 작동하여 페이지에 예기치 못한 영향을 끼칠 수 있다는 것이다. 따라서, 코드를 아래와 같이 수정한다.
// assets/scripts/everyday.client.js
// ...
initTable() {
Object.assign($ELEMENTS, {
"login.username": {
element: document.querySelector("#login-id")
},
"login.password": {
element: document.querySelector("#login-pass")
},
"login.submit_button": {
element: document.querySelector("#login-btn"),
eventListeners: [
{
event: "click",
eventListener: Client.loginPage.submit
}
]
},
"mails.load": {
element: document.querySelector("#mails-page"), // <-- 추가
eventListeners: [
{
event: "initialize",
eventListener: Client.mailListPage.load
}
]
}
});
},
listen() {
for (const v of Object.values($ELEMENTS)) {
if (v.element == null) // <-- 추가
continue;
for (const listener of (v.eventListeners ?? [])) {
if (listener.event == "initialize")
listener.eventListener();
else
v.element.addEventListener(listener.event, listener.eventListener);
}
}
}
// ...
페이지에 해당하는 DOM 개체가 있는지 확인한 다음, 개체가 존재할 경우에만 이벤트 리스너 할당 및 실행 코드가 동작하게끔 설계해 둠으로써, 불필요한 경우에도 이벤트 리스너가 실행되는 것을 막았다.
이번에는 리스너로서 사용하려는 Client.mailListPage.load(startIdx?, size?) 메서드를 작성한다. Client 객체 내에 mailListPage 객체를 만들고, 그 안에 메서드를 정의한다.
// assets/scripts/everyday.client.js
// ...
const Client = {
// ...
mailListPage: {
async load(startIdx = 0, size = 20) {
const mailList = await sendRequest(GET, `/endpoints/mails?start=${startIdx}&size=${size}`);
console.log(mailList);
}
},
// ...
};
// ...
이 메서드는 메일 리스트 엔드포인트에 시작 메일 인덱스와 응답 크기를 담은 요청을 보내고 이에 응하는 응답을 받는 역할을 한다. 따라서 엔드포인트에 요청을 전송하기 위해 이전 글에서 정의했던 sendRequest(method, uri, body?) 함수를 사용할 것이다.
메서드의 내용은 나중에 구현하고, 일단은 /mails로 접근하면 메서드가 실행되는지 확인해보기 위해서 ejs 파일을 만들고, 라우트에 할당해 본다.
<!-- view/mail_list.ejs -->
<!DOCTYPE html>
<html lang="zxx">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="/assets/scripts/everyday.client.js"></script>
</head>
<body>
<div class="wrap" id="mails-page">
<table>
<thead>
<tr>
<th>발신자</th>
<th>제목</th>
<th>첨부파일</th>
<th>수신 시각</th>
</tr>
</thead>
<tbody id="mail-list">
</tbody>
</table>
</div>
</body>
</html>
// route/public.route.js
// ...
router.use("/mails", checkUserPermission);
router.get("/mails", (req, res) => {
// Mailbox
res.render("mail_list");
});
// ...
작성한 ejs 파일의 구조에 따라서, load 메서드에서는 메일의 내용을 받아 가공하여 tbody#mail-list에 적절히 삽입해 주어야 한다. 받는 응답이 JSON 배열 구조이므로, Array.map(callback) 메서드를 사용하여 HTML 구조를 만들고 이를 삽입하는 방향으로 생각해 본다. 엔드포인트에서 돌려주는 응답은 다음과 같은 형식을 가진다.
[
{
idx: 1, // 메일 인덱스
from: {
value: [
{
name: "", // 보내는 사람 이름
address: "" // 보내는 사람 메일
}
]
},
subject: "", // 제목
hasAttachment: true // 첨부파일 여부
}
]
위 형식을 이용하여 메일 리스트를 보여주는 코드를 작성한다.
// assets/scripts/everyday.client.js
// ...
const getDateString = (rawDate) => {
const date = new Date(rawDate);
let dateStr = `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}. `;
dateStr += `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
return dateStr;
};
const Client = {
// ...
mailListPage: {
async load(startIdx = 0, size = 20) {
const raw = await sendRequest(GET, `/endpoints/mails?start=${startIdx}&size=${size}`);
const mailList = JSON.parse(raw);
const trArr = mailList.map((mail) => {
const sendersLength = mail.from.value.length;
const firstSender = mail.from.value[0];
let firstSenderName = firstSender.name;
const hasMultipleSenders = sendersLength > 1;
if (firstSenderName.trim() == "")
firstSenderName = firstSender.address;
const date = getDateString(mail.date);
const hasAttachment = mail.hasAttachment;
return `
<tr>
<td>${firstSenderName} ${hasMultipleSenders ? ` + ${sendersLength - 1}` : ""}</td>
<td><a href="/mails/${mail.idx}">${mail.subject}</a></td>
<td>${hasAttachment ? "✔️" : "❌"}</td>
<td>${date}</td>
</tr>
`;
});
$ELEMENTS["mails.list"].element.innerHTML = trArr.join("");
}
},
// ...
initTable() {
Object.assign($ELEMENTS, {
// ...
"mails.list": {
element: document.querySelector("#mail-list"); // tbody
}
});
}
// ...
};
이제 로그인 후 /mails에 접근해 보면 메일 리스트가 로드되는 모습을 볼 수 있다. 발신인의 이름이 없을 경우, 발신인의 메일 주소를 표시하는 모습, 첨부파일 여부가 잘 표시되는 모습, 받은 날짜 또한 형식에 맞추어 표시되는 모습을 전부 볼 수 있다.
Step 13.
프론트엔드: 메일 상세
메일 리스트에 표시되는 링크를 클릭하면 메일 상세 페이지로 이동한다. 이 페이지는 제목, 발신자, 날짜, 내용, 첨부 파일 등 메일에 포함된 여러 데이터들을 사용자에게 표시한다. 메일 리스트 페이지를 만들 때처럼, ejs 파일을 만들고 라우트에 할당한다.
<!-- view/mail_detail.ejs -->
<!DOCTYPE html>
<html lang="zxx">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="/assets/scripts/everyday.client.js"></script>
</head>
<body>
<div class="wrap" id="mail-detail-page">
<a href="/mails">Back</a>
<h1 id="mail-title"></h1>
<p><b>From:</b> <span id="mail-sender"></span></p>
<p><b>To:</b> <span id="mail-receiver"></span></p>
<p><b>Date:</b> <span id="mail-date"></span></p>
<hr>
<iframe id="mail-content" frameborder="0"></iframe>
<div id="mail-attachments"></div>
</div>
</body>
</html>
// route/public.route.js
// ...
router.use("/mails/:mail_idx", checkUserPermission);
router.get("/mails/:mail_idx", (req, res) => {
// Mail detail
res.render("mail_detail");
});
// ...
메일 리스트 페이지의 로드 이벤트 리스너를 작성했던 것과 같이, $ELEMENTS 상수에 DOM 개체와 그에 대한 이벤트 리스너를 명시한다.
// assets/clients/everyday.client.js
// ...
const Client = {
// ...
initTable() {
Object.assign($ELEMENTS, {
// ...
"mails.detail.load": {
element: document.querySelector("#mail-detail-page"),
eventListeners: [
{
event: "initialize",
eventListener: Client.mailDetailPage.load
}
]
}
});
}
// ...
};
//...
이후 Client.mailDetailPage.load 메서드를 구현한다. 엔드포인트(/endpoints/mails/:mail_idx)에 메일 데이터를 요청하여 돌려받은 응답을 사용자에게 표시하는 역할을 가진다. 물론, 해당 메서드에서 사용할 DOM 개체들을 $ELEMENTS 상수에 명시해 주어야 한다.
// assets/scripts/everyday.client.js
// ...
const Client = {
// ...
initTable() {
Object.assign($ELEMENTS, {
// ...
"mail.detail.subject": {
element: document.querySelector("#mail-title")
},
"mail.detail.from": {
element: document.querySelector("#mail-sender")
},
"mail.detail.to": {
element: document.querySelector("#mail-receiver")
},
"mail.detail.date": {
element: document.querySelector("#mail-date")
},
"mail.detail.content": {
element: document.querySelector("#mail-content")
},
"mail.detail.attachments": {
element: document.querySelector("#mail-attachments")
}
});
}
// ...
};
// ...
본격적으로 Client.mailDetailPage.load 메서드를 작성한다. 이 메서드의 내용을 작성하기 위해 필요한 엔드포인트 구조는 다음과 같다.
{
attachments: [
{
checksum: "",
content: {
type: "Buffer",
data: [] // 버퍼 데이터 등, type에 따른 raw 데이터
},
contentDisposition: "attachment",
contentType: "audio/mpeg", // MIME 타입
filename: "0000.mp3", // 파일 이름
headers: {},
size: 0 // 파일 크기
}
],
from: {
value: [
{
name: "", // 발신자 이름
address: "" // 발신자 메일 주소
}
],
html: "", // HTML 형식으로 표시된 발신자 리스트
text: "", // 텍스트 형식으로 표시된 발신자 리스트
},
to: {
value: [
{
name: "", // 수신자 이름
address: "" // 수신자 메일 주소
}
],
html: "", // HTML 형식으로 표시된 수신자 리스트
text: "", // 텍스트 형식으로 표시된 수신자 리스트
},
headerLines: [
// 메일 헤더 Raw 목록
],
headers: [
// 메일 헤더 목록
],
html: "", // 메일 내용 HTML
idx: 1, // 메일 인덱스
messageId: "", // 메일 메시지 ID
subject: "", // 메일 제목
}
위 구조에서 필요한 부분만을 추출하여 DOM 개체의 내용에 삽입함으로써 메일 상세 표시 기능을 완성한다. 메일의 첨부 파일은 데이터를 Blob으로 읽어 들여 Object URL로 만들고, a 태그의 href 속성에 적용함으로써 다운로드할 수 있게끔 만들면 된다.
// assets/scripts/everyday.client.js
// ...
const Client = {
// ...
mailDetailPage: {
async load() {
const idx = window.location.pathname.split("/")[2];
const raw = await sendRequest(GET, `/endpoints/mails/${idx}`);
const detail = JSON.parse(raw);
const from = detail.from.html;
const to = detail.to.html;
const { subject, date, attachments } = detail;
const dateStr = getDateString(date);
const body = detail.html;
const attachmentLinks = attachments.map(attachment => {
const blob = new Blob(attachment.content.data, {
type: attachment.contentType
});
const url = URL.createObjectURL(blob);
const filename = attachment.filename;
return `<a download="${filename}" href="${url}">${filename}</a>`
});
$ELEMENTS["mail.detail.subject"].element.innerHTML = subject;
$ELEMENTS["mail.detail.from"].element.innerHTML = from;
$ELEMENTS["mail.detail.to"].element.innerHTML = to;
$ELEMENTS["mail.detail.date"].element.innerHTML = dateStr;
$ELEMENTS["mail.detail.attachments"].element.innerHTML = attachmentLinks.join("");
const iframe = $ELEMENTS['mail.detail.content'].element;
const iframeDoc = iframe.contentWindow.document;
const div = iframeDoc.createElement("div");
div.innerHTML = body;
iframeDoc.body.appendChild(div);
}
},
// ...
}
// ...
iframe의 내용에는 document.createElement(tagSelector), document.appendChild(node) 메서드를 활용하여 받은 메일 데이터의 HTML 데이터를 삽입한다.
마치며
이로써 메일 리스트와 메일 상세 기능 구현을 모두 완료했으므로 이제 메일 전송과 관련된 프론트엔드 페이지의 구현만이 남았다. 마찬가지로, ejs 생성과 이벤트 리스너 추가, 그리고 엔드포인트와의 통신만으로 구현해 낼 수 있다.
만약 이 프로젝트가 당신에게 유익하거나, 참고가 될 만한 프로젝트라고 생각한다면, 아래의 공감 버튼을 한 번만 클릭해 주시길 부탁드린다. 필자는 여러분의 의견을 바탕으로 이 글이 얼마나 유익했는지, 얼마나 도움이 되었는지, 소위 '양질의 컨텐츠'인지 다시금 생각해 보아야 한다. 앞으로도 여러분들에게 도움이 되는 글을 작성하기 위해서는 여러분의 도움이 필요하다.
이 글에서 오류가 있거나, 지나치게 편향적이거나 주관적인 서술이 있다고 생각하거나, 이해를 방해하는 내용이나 적절하지 못한 문체를 포함하고 있는 등의 문제가 있다고 생각된다면 언제든지 댓글로 피드백을 작성하여 오류 수정에 도움을 주실 수 있다. 긴 글 읽어 진심으로 감사드린다.
'개발일지 > 웹' 카테고리의 다른 글
Node.js에서 WinRT API로 재생 중인 미디어 정보 가져오기: NodeRT (1) | 2024.08.01 |
---|---|
텍스트 에디터로 메일 전송 페이지 만들기: 웹메일 클라이언트 개발하기 6 (0) | 2024.07.28 |
로그인 페이지 구현해보기: 웹메일 클라이언트 개발하기 4 (0) | 2024.07.20 |
웹 우체국으로 편지 보내는 방법: 웹메일 클라이언트 개발하기 3 (0) | 2024.07.19 |
메일은 어떻게 받아오는가: 웹메일 클라이언트 개발하기 2 (0) | 2024.07.16 |