필자가 개발하고자 하는 웹메일 클라이언트의 프로토타입은 이 글의 내용을 구현함으로써 완성된다. 앞으로의 내용은 UI, UX와 같은 디자인과 관련된 내용이 될 것이기에 연재되지 않을 것이므로, 지금 이 글과 다음 글이 웹메일 클라이언트 개발하기의 마지막 내용이 될 것이다.
SMTP와 SMTP를 활용한 라이브러리인 nodemailer를 사용하는 메일 전송 엔드포인트 개발은 이 글의 Step 8에서 진행하였으므로 아직 읽지 않았다면 한 번쯤 읽어보기를 권장한다. 또한, 이 프로젝트의 진행 과정에 흥미가 있거나, 메일 클라이언트 개발에 참고하고 싶다면 '웹메일 클라이언트 개발하기' 태그 페이지에 방문하여 내용을 읽어보는 것도 좋다.
이 글은 프로젝트 개발의 핵심 내용과 원리를 정리하고자 하는 글일 뿐, 강의를 하려는 목적의 글이 아니다. 따라서, 독자가 HTML, CSS, JavaScript와 같은 웹 프론트엔드의 기초 지식과 Node.js, Express.js와 같은 Node.js 기반 웹 백엔드의 기초 지식을 알고 있다는 가정 하에 작성되었다. 따라서 이러한 기초 지식 없이 글을 읽는다면 당신의 의문점이나 관심사에 큰 도움을 주지 못할 수 있다. 하지만 당신이 실제 서비스를 비슷하게 구현해보고 싶거나, 마땅히 개발에 사용할 아이디어가 없는 사람이라면 이 글이 참고가 될 수도 있다.
Step 14.
프론트엔드: 메일 전송
메일 전송 페이지는 사용자에게 제목, 수신 메일 주소, 첨부 파일, 본문 등 여러 입력 창을 보여주고, 내용을 받아 엔드포인트로 내용을 전송하는 역할을 하는 페이지이다. 이 점을 고려하여 메일 전송 페이지의 ejs 파일을 작성하고, 라우트를 연결한다.
<!-- view/mail_write.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-form">
<div>
<label for="subject">Subject</label>
<input type="text" id="subject" name="subject">
</div>
<br>
<div>
<label for="to">To</label>
<div class="to-list"></div>
<input type="text" id="to" name="to">
<button id="to-add">Add</button>
</div>
<br>
<div class="editor-wrap">
<p>Content</p>
<div class="editor-menu">
<button id="bold-btn" type="button">
<b>Bold</b>
</button>
<button id="italic-btn" type="button">
<i>Italic</i>
</button>
<button id="underline-btn" type="button">
<span style="text-underline-offset: 16%; text-decoration: underline">Underline</span>
</button>
<button id="line-through-btn" type="button" style="margin-right: 30px;">
<span style="text-decoration: line-through">Line-Through</span>
</button>
<div>
<span style="display: inline-block; margin-right: 10px; font-size: .9em">Text Size</span>
<button class="text-size-btn" type="button" data-prop="1">Small</button>
<button class="text-size-btn" type="button" data-prop="3">Normal</button>
<button class="text-size-btn" type="button" data-prop="5">Big</button>
<button class="text-size-btn" type="button" data-prop="7">Bigger</button>
</div>
</div>
<div id="editor" style="width: 400px; height: 200px" contenteditable="true"></div>
<textarea id="editor-raw" readonly style="display: none"></textarea>
</div>
<br>
<div>
<label for="attachment">Attachments</label>
<div id="attachment-list"></div>
<input type="file" name="attachment" id="attachment">
</div>
<br>
<button id="send">Send</button>
</div>
</body>
</html>
// route/public.route.js
// ...
router.use("/create", checkUserPermission);
router.get("/create", (req, res) => {
// Create a new email
res.render("mail_write");
});
// ...
ejs 파일의 HTML 구조 내에 있는 각 입력창을 $ELEMENTS에 넣음으로써 추후에 쉽게 접근할 수 있도록 만든다. 텍스트 에디터의 관련된 버튼은 $ELEMENTS에 넣지 않고 추후에 메서드에서 이벤트 리스너를 등록한다.
// assets/scripts/everyday.client.js
// ...
const Client = {
// ...
initTable() {
Object.assign($ELEMENTS, {
// ...
"write.input.subject": {
element: document.querySelector("#subject")
},
"write.input.to": {
element: document.querySelector("#to")
},
"write.input.body": {
element: document.querySelector("#editor-raw"),
eventListeners: [
{
event: "input",
eventListener: Client.writePage.onBodyInput
}
]
},
"write.input.attachment": {
element: document.querySelector("#attachment"),
eventListeners: [
{
event: "input",
eventListener: Client.writePage.onFileInput
}
]
},
"write.button.toAdd": {
element: document.querySelector("#to-add"),
eventListeners: [
{
event: "click",
eventListener: Client.writePage.addReceiver
}
]
},
"write.button.send": {
element: document.querySelector("#send"),
eventListeners: [
{
event: "click",
eventListener: Client.writePage.send
}
]
},
"write.list.to": {
element: document.querySelector(".to-list")
},
"write.list.attachments": {
element: document.querySelector("#attachment-list")
},
"write.editor": {
element: document.querySelector("#editor"),
eventListeners: [
{
event: "initialize",
eventListener: Client.writePage.initEditor
},
{
event: "input",
eventListener: Client.writePage.onBodyInput
}
]
}
});
},
// ...
};
이벤트 리스너로 사용한 ..writePage 객체의 여러 메서드들을 정의한다.
// assets/scripts/everyday.client.js
// ...
const Client = {
// ...
writePage: {
onBodyInput() {
},
onFileInput() {
},
addReceiver() {
},
initEditor() {
},
async send() {
}
},
// ...
};
먼저 구현할 것은 ..writePage.toAdd() 메서드, 즉 받는 사람을 추가하는 메서드이다. input#to 개체의 값을 배열에 추가하고, 배열의 내용을 div.to-list 개체 내에 표시한다.
// assets/scripts/everyday.client.js
// ...
const Client = {
// ...
writePage: {
to: new Set(),
// ...
addReceiver() {
const address = $ELEMENTS["write.input.to"].element.value;
const isEmpty = address.trim().length < 1;
const emailExp = /^[a-zA-Z0-9+-\_.]+@[a-zA-Z]+\.[a-zA-Z]+$/;
if (isEmpty || !emailExp.test(address))
return;
Client.writePage.to.add(address);
Client.writePage.showReceivers();
},
showReceivers() {
$ELEMENTS["write.list.to"].element.innerHTML = "";
for (const address of Client.writePage.to) {
const element = document.createElement("p");
element.innerHTML = address;
element.addEventListener("click", _ => Client.writePage.removeReceiver(address));
$ELEMENTS["write.list.to"].element.appendChild(element);
}
},
removeReceiver(address) {
Client.writePage.to.delete(address);
Client.writePage.showReceivers();
},
// ...
},
// ...
};
// ...
첨부 파일 입력란은 받는 메일 입력란과 비슷한 방식으로 작동되므로 쉽게 구현할 수 있다. ..writePage.onFileInput(event) 메서드를 구현한다.
// assets/scripts/everyday.client.js
// ...
const Client = {
// ...
writePage: {
// ...
files: [],
// ...
onFileInput(event) {
for (const file of event.target.files) {
const fr = new FileReader();
fr.onload = () => {
const filename = file.name;
const content = fr.result.split(",")[1];
Client.writePage.files.push({ filename, content });
Client.writePage.showFiles();
}
fr.readAsDataURL(file);
}
},
showFiles() {
$ELEMENTS["write.list.attachments"].element.innerHTML = "";
for (const [i, { filename }] of Object.entries(Client.writePage.files)) {
const element = document.createElement("p");
element.innerHTML = filename;
element.addEventListener("click", _ => Client.writePage.removeFile(i));
$ELEMENTS["write.list.attachments"].element.appendChild(element);
}
console.log(Client.writePage.files);
},
removeFile(idx) {
Client.writePage.files = Client.writePage.files.filter((v, i) => i != idx);
Client.writePage.showFiles();
},
// ...
},
// ...
};
// ...
이번에는 에디터에 관련된 이벤트 리스너들을 구현한다. ..writePage.initEditor()를 먼저 구현해 본다. contenteditable 속성이 적용되어 있는 div 개체는 document.execCommand(command)으로 폰트, 크기 등을 설정할 수 있다.
// assets/scripts/everyday.client.js
// ...
const Client = {
// ...
writePage: {
// ...
initEditor() {
const q = s => document.querySelectorAll(s);
const setStyle = c => document.execCommand(c) || focusEditor();
const focusEditor = _ => editor.focus({ preventScroll: true });
const editor = $ELEMENTS["write.editor"];
const boldBtn = q("#bold-btn")[0];
const italicBtn = q("#italic-btn")[0];
const underlineBtn = q("#underline-btn")[0];
const strikeBtn = q("#line-through-btn")[0];
const textSizeBtns = q(".text-size-btn");
boldBtn.addEventListener("click", _ => setStyle("bold"));
italicBtn.addEventListener("click", _ => setStyle("italic"));
underlineBtn.addEventListener("click", _ => setStyle("underline"));
strikeBtn.addEventListener("click", _ => setStyle("strike"));
textSizeBtns.forEach(v => {
v.onclick = _ => {
document.execCommand("fontSize", false, v.getAttribute("data-prop"));
};
});
},
// ...
},
// ...
};
// ...
내용을 수정할 때마다 textarea에 수정된 내용이 반영되도록, 정확히는 textarea의 내용과 contenteditable div의 내용을 동기화하도록 ..writePage.onBodyInput(event) 메서드를 작성한다.
// assets/scripts/everyday.client.js
// ...
const Client = {
// ...
writePage: {
// ...
onBodyInput(event) {
const isTextArea = event.target instanceof HTMLTextAreaElement;
const html = event.target.value ?? event.target.innerHTML;
if (isTextArea)
$ELEMENTS["write.editor"].element.innerHTML = html;
else {
$ELEMENTS["write.input.body"].element.value = html;
}
},
// ...
},
// ...
};
// ...
모든 내용을 다 받았으므로 실제로 메일을 전송하는 메서드인 ..writePage.send()를 작성함으로써 메일 전송 기능을 마무리한다.
// assets/scripts/everyday.client.js
// ...
const Client = {
// ...
writePage: {
// ...
async send() {
const subject = $ELEMENTS["write.input.subject"].element.value;
const to = [...Client.writePage.to];
const attachments = Client.writePage.files;
const html = $ELEMENTS["write.input.body"].element.value;
if (subject.trim() == "" || to.length == 0 || html.trim() == "")
return;
await sendRequest(POST, "/endpoints/mails", {
to, subject, html, attachments
});
location.reload();
}
// ...
},
// ...
};
// ...
이대로 메일을 전송하려고 시도하면 오류가 발생하는데, 이는 엔드포인트가 받는 사람을 한 명으로 상정하고 작성되었기 때문이다. 따라서, 받는 사람이 여러 명일 수 있도록 엔드포인트를 수정해 준다.
// route/endpoint.route.js
// ...
router.post("/mails", async (req, res) => {
try {
// ...
if (to.length == 0 || [ subject, html ].some(e => e == null || e.trim() == ''))
return sendResponse(res, 400, "Bad Request");
// ...
} catch (e) {
// ...
}
}
// ...
이후, 메일을 전송하여 본다. 받는 사람과 첨부파일을 설정하여 잘 전송되는지 확인한다.
마치며
이렇게, 웹메일 클라이언트의 프로토타입이 완성되었다. 앞으로는 조금의 UI/UX 디자인을 적용하여 GitHub에 소스 코드를 공개하고 누구나 사용할 수 있도록 만들 생각이므로, 많은 관심 부탁드린다. 이후의 UI/UX, 또는 추가 과정을 작업하는 개발일지는 누구나 볼 수 있게 공개할 생각은 아직 없지만, 추후에 공개할 수 있다면 글로 작성해 보도록 노력해 보겠다.
이 프로젝트가 당신에게 유익하다거나, 참고가 되는 프로젝트라고 생각된다면, 아래의 공감 버튼을 한 번 클릭해 주시길 부탁드린다. 여러분의 의견을 바탕으로 이 글이 유익한지, 실제로 독자에게 도움이 되었는지 다시금 생각해 볼 수 있고, 이는 곧 양질의 컨텐츠를 생산하는 것으로 이어진다. 앞으로 여러분들에게 도움이 되는 글을 작성하기 위해 여러분들의 도움이 필요하다.
이 글에 오류가 있거나, 지나치게 편향적이거나 주관적인 서술이 존재한다고 생각되거나, 이해를 방해하는 내용, 문체 등을 포함하고 있다고 느껴진다면 언제든지 댓글로 피드백을 작성해 주시어 오류 수정에 도움을 주실 수 있다. 6부 분량에 이르는 이 긴 과정을 읽어주셔서 진심으로 감사드린다.
'개발일지 > 웹' 카테고리의 다른 글
Node.js에서 WinRT API로 재생 중인 미디어 정보 가져오기: NodeRT (1) | 2024.08.01 |
---|---|
웹메일들은 어떻게 메일을 보여주는가: 웹메일 클라이언트 개발하기 5 (0) | 2024.07.23 |
로그인 페이지 구현해보기: 웹메일 클라이언트 개발하기 4 (0) | 2024.07.20 |
웹 우체국으로 편지 보내는 방법: 웹메일 클라이언트 개발하기 3 (0) | 2024.07.19 |
메일은 어떻게 받아오는가: 웹메일 클라이언트 개발하기 2 (0) | 2024.07.16 |