개발일지/웹

텍스트 에디터로 메일 전송 페이지 만들기: 웹메일 클라이언트 개발하기 6

데키유 2024. 7. 28. 18:19

필자가 개발하고자 하는 웹메일 클라이언트의 프로토타입은 이 글의 내용을 구현함으로써 완성된다. 앞으로의 내용은 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부 분량에 이르는 이 긴 과정을 읽어주셔서 진심으로 감사드린다.