PyQt5를 이용한 이메일 클라이언트(다중 계정지원)

개요

이번에 소개할 예제는 MS 아웃룩(Outlook)과 비슷하게 만들어본 이메일 클라이언트 입니다.

예제로서 소개하기 위해 아웃룩처럼 복잡한 기능은 빼고, IMAP을 지원하는 이메일 계정에 접속해 메일을 읽어 오는 기능까지만 구현하였습니다.

로그인 부분에서 다수의 이메일 계정을 입력하면 추가로 탭을 구성하고 메일수신이 가능합니다.

먼저 전자메일 서버에 로그인 해야 합니다.

대부분 전자 메일 서버는 IMAP을 지원하며, 서버에 2단계 인증앱 비밀번호를 미리 등록해 두어야 합니다.

(앱 비번은 로그인 비번 X, 전자메일 전용 비밀번호 O)

[ 예) Goggle 보안 - 2단계 인증, 앱 비밀번호 생성 ]

해당 전자메일 전용 앱 비밀번호를 이용해 로그인을 시도합니다.

[eMail Account]

전자메일 서버 로그인 후, 읽어들인 전자메일을 QTableWidget 에 표시.

[eMail Contents]

 

유의 사항

본인 메일서버의 IMAP 기능을 활성화 시킨 후 적용하기 바랍니다.

POP3는 지원하지 않으며, IMAP SSL (TLS X)만 읽을 수 있도록 설정해 두었습니다.

TLS 암호화를 사용하는 분은 소스코드의 로그인 부분만 수정하면 됩니다.

SMTP는 향후 추가할 계획입니다.

예제에서 테스트된 메일 서버는 Naver, Google Gmail 입니다. 

[Google Gmail IMAP Setting]

 

개발 환경

  • MS Windows 11 Pro, MS Visual Studio 2022
  • Python 3.9, PyQt5 5.15.4


소스코드

총 3개의 소스코드 파일로 구성. (main.py, login.py, imap.py)

main.py파일을 시작파일로 구성후 실행.

QTableWidget로그인 위젯IMAP 위젯 2개가 탭추가되는 방식으로 구성.


main.py 소스코드

QTabWidget에서 상속받은 메인 위젯을 생성.

from PyQt5.QtWidgets import QApplication, QTabWidget
from PyQt5.QtCore import Qt
import sys
from login import Login_Widget
from imap import IMAP_Widget

QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)

class Window(QTabWidget):

    def __init__(self):
        super().__init__()
        self.setWindowTitle('Email Client (Ocean Coding School)')

        lw = Login_Widget(self)
        self.addTab(lw, lw.windowTitle())

    def onLogin(self, domain, id, pw):
        self.iw = IMAP_Widget(domain, id, pw)
        self.addTab(self.iw, self.iw.windowTitle())


if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = Window()
    w.show()
    sys.exit(app.exec_())

[라인 1~7]

필요 모듈을 불러오는 부분.


[라인 11~16]

탭위젯에서 상속받은 Window 클래스 생성자 함수.

아래에 소개할 로그인 위젯 객체를 생성하고 탭에 추가.


[라인 18~20]

로그인 위젯에서 입력받은 이메일 계정, 비번으로 로그인 성공한 경우 호출되는 슬롯함수.

아래에 소개할 IMAP 위젯 객체를 생성하고 탭에 추가.


login.py 소스코드

이메일 계정에 로그인하기 위한 아이디, 비밀번호 입력 위젯을 생성.

from PyQt5.QtWidgets import (QWidget, QGroupBox, QLabel, QLineEdit, QPushButton,
                            QVBoxLayout, QHBoxLayout, QMessageBox, QSpacerItem, QSizePolicy)

from PyQt5.QtCore import pyqtSignal

class Login_Widget(QWidget):

    login_ok = pyqtSignal(str, str, str)

    def __init__(self, w):
        super().__init__()
        self.parent = w
        self.setWindowTitle('Login')
        self.initUi()

        self.login_ok.connect(self.parent.onLogin)

    def initUi(self):
        lbl = QLabel('ID')
        self.id = QLineEdit()

        idbox = QHBoxLayout()
        idbox.addWidget(lbl)
        idbox.addWidget(self.id)

        lbl = QLabel('PW')
        self.pw = QLineEdit()
        self.pw.setEchoMode(QLineEdit.Password)

        pwbox = QHBoxLayout()
        pwbox.addWidget(lbl)
        pwbox.addWidget(self.pw)

        gb = QGroupBox('Email Account')
        vbox = QVBoxLayout()
        vbox.addLayout(idbox)
        vbox.addLayout(pwbox)
        self.btn = QPushButton('Check account')
        vbox.addWidget(self.btn)
        gb.setLayout(vbox)
        spacer = QSpacerItem(0,0,QSizePolicy.Expanding, QSizePolicy.Expanding)

        vbox = QVBoxLayout()
        vbox.addWidget(gb)
        vbox.addSpacerItem(spacer)
        self.setLayout(vbox)

        self.btn.clicked.connect(self.onClick)

    def onClick(self):        
        email   = self.id.text()
        pw      = self.pw.text()
        if email:
            user, domain = email.split('@')
            import imaplib
            try:
                imap = imaplib.IMAP4_SSL(f'imap.{domain}')                                
                imap.login(email, pw)                
            except Exception as e:
                QMessageBox.critical(self, 'Error, check server or id, pw!', str(e), QMessageBox.Ok)
            else:
                QMessageBox.information(self, 'Imap server', 'Login Ok', QMessageBox.Ok)                
                imap.logout()

                self.login_ok.emit(domain, email, pw)
        else:
            QMessageBox.critical(self, 'Error', 'Input id, pw', QMessageBox.Ok)

[라인 1~4]

필요 모듈을 불러오는 부분.

 

[라인 6~16]

QWidget에서 상속받은 Login 위젯 생성부.

로그인화면을 구성하고 로그인 성공시 보낼 Signal, Slot 함수의 연결을 처리.


[라인 18~48]

아이디, 비밀번호를 입력하기 위한 GUI 화면 구성.

Qt Designer를 이용하지 않고, 코드에서 직접 컨트롤들을 생성.

(QGroupBox, QLabel, QLineEdit, QPushButton 등)


[라인 50~67]

로그인 버튼을 누를때 호출되는 슬롯 함수.

입력받은 이메일 계정, 비밀번호를 이용해 메일서버에 접속시도.

파이썬의 imaplib 모듈을 활용.

접속 성공시, 로그인 성공 신호를 main.py 로 전송.

 

imap.py 소스코드

이메일 IMAP서버 접속 후 받은 편지함의 메일을 읽고 표시하는 위젯 생성.

from PyQt5.QtWidgets import (QWidget, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
                            QVBoxLayout, QHBoxLayout, QPushButton, QProgressBar, QMessageBox)

from PyQt5.QtCore import pyqtSignal

from threading import Thread
import imaplib
import email
from email import policy

class IMAP_Widget(QWidget):

    update_table = pyqtSignal(tuple)
    update_bar = pyqtSignal(int)

    def __init__(self, domain, id, pw):
        super().__init__()
        self.setWindowTitle(f'IMAP-{id}')
        self.domain = domain
        self.id = id
        self.pw = pw

        self.t = Thread()

        self.initUi()
        self.readMail(domain, id, pw)

    def initUi(self):        
        self.bar = QProgressBar()
        self.update_bar.connect(self.updateBar)
        self.pb = QPushButton('Refresh')
        self.pb.clicked.connect(self.onRefresh)        

        hbox = QHBoxLayout()
        hbox.addWidget(self.bar)
        hbox.addWidget(self.pb)

        # table widget 
        self.tw = QTableWidget()
        label = ('Date', 'From', 'Title')
        self.tw.setColumnCount( len(label) )
        self.tw.setHorizontalHeaderLabels(label)
        self.tw.setAlternatingRowColors(True)
        self.tw.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self.tw.setSelectionBehavior(QAbstractItemView.SelectRows)
        h = self.tw.horizontalHeader()
        h.setSectionResizeMode(QHeaderView.ResizeToContents)
        self.update_table.connect(self.updateTable)

        vbox = QVBoxLayout()
        vbox.addLayout(hbox)        
        vbox.addWidget(self.tw)

        self.setLayout(vbox)

    def onRefresh(self):
        self.readMail(self.domain, self.id, self.pw)

    def readMail(self, domain, id, pw):  
        if not self.t.is_alive():
            self.tw.setRowCount(0)
            self.t = Thread(target=self.threadFunc, args=(domain, id, pw) )
            self.t.start()
        else:
            QMessageBox.critical(self, 'Error', 'Reading. try again later.', QMessageBox.Ok)

    def threadFunc(self, domain, id, pw):
        imap = self.imap_login(domain, id, pw)
        self.imap_listup(imap)
        self.imap_readmail(imap)


    def imap_login(self, domain, id, pw):
        try:
            imap = imaplib.IMAP4_SSL(f'imap.{domain}')
            code, resp = imap.login(id, pw)
        except Exception as e:
            print(e)            
        else:
            print(code)
            print(resp[0].decode())
            return imap

    def imap_listup(self, imap):
        try:
            code, resp = imap.list()
        except Exception as e:
            print(e)
        else:
            print(code)
            for d in resp:
                print(d.decode())

    def imap_readmail(self, imap):        
        code, resp = imap.select('INBOX')
        code, resp = imap.search(None, 'All')
        mail_ids = resp[0].decode().split()

        self.bar.setRange(0, len(mail_ids)-1)
        
        for i, mid in enumerate(mail_ids):
            code, resp = imap.fetch(mid, '(RFC822)')

            byte_data = resp[0][1]
            msg = email.message_from_bytes(byte_data, policy=policy.default)

            self.update_bar.emit(i)
            self.update_table.emit( (msg['date'], msg['from'], msg['Subject']) )

            print('Date:', msg['data'])
            print('From:', msg['from'])
            print('Subject:', msg['Subject'])

    def updateBar(self, v):
        self.bar.setValue(v)

    def updateTable(self, data):
        row = self.tw.rowCount()
        self.tw.setRowCount(row + 1)

        for i in range(len(data)):
            item = QTableWidgetItem(data[i])
            self.tw.setItem(row, i, item)
        
        self.tw.selectRow(row)

[라인 1~9]

필요 모듈을 불러오는 부분.

 

[라인 11~26]

QWidget에서 상속받은 IMAP 위젯 생성부.

향후 필요한 Signal을 선언하고, 화면 구성을 초기화.

 

[라인 28~54]

읽어들인 메일 내용을 표시하기 위한 GUI 구성.

Qt Designer 를 사용하지 않고 코드에서 직접 컨트롤 객체 생성.

(QTableWidget, QProgressBar, QPushButton 등)

 

[라인 56~57]

이메일 읽기함수를 재 호출해 새로운 메일이 있는 경우 표시.

현재는 모든 QTableWidget에 표시된 모든 이메일을 삭제(서버는 유지) 하고 다시 불러들이는 비효율적인 방식으로 구성.

(향후 수신된 메일과 비교, 다른 부분만 업데이트하는 로직 개선 필요)

 

[라인 59~65]

이메일 읽기 쓰레드가 이미 동작중인지 체크 후, 읽기중이 아니면 Thread 생성.


[라인 67~70]

IMAP 객체 생성 🔜 IMAP 메일박스 리스트업 🔜 "받은편지함" 읽기 순서로 함수 호출.


[라인 73~82]

Python imaplib module을 이용, IMAP4_SSL 객체 생성.


[라인 84~92]

이메일 계정의 메일 박스 List Up.

(받은 편지함, 보낼편지함, 정크메일 등...)


[라인 94~112]

받은 편지함의 모든 이메일 ID 확보.

모든 이메일 ID를 반복하며 날짜, 보낸사람, 제목 정보를 QTableWidget으로 Update.

(Thread에서 호출되는 함수이므로, 여기서 직접 QTableWidget에 접근 X)

동시에 QProgressBarUpdate하여 진행율 표시.

(진행바는 받은 편지함 메일 수량으로 범위 지정)


[라인 114~115]

메일을 읽을 때 마다 호출되는 함수.

QProgressBar 컨트롤의 진행율 Update.


[라인 117~125]

메일을 읽을 때 마다 호출되는 함수.

QTableWidget 에 새로운 행을 추가하고 메일 날짜, 보낸사람, 제목을 내용에 추가.

동시에 행의 포커스도 이동시켜 수직 스크롤바 Update 되도록 구성.


이상으로 모든 설명을 마칩니다.

참고로 이 예제는 학원생 중 초등 6학년생이 만든 예제에서 영감을 받아 제작되었습니다.

지원아 고마워, 감사합니다.

댓글

  1. 네 선생님 저도 늦었지만 너무 감사했어요. 지금은 비록 해운대로 이사를 와서 자주 갈 수 없지만 그래도 선생님께 배웠던 파이썬은 아직 열심히 하고 있어요. 가끔 놀러갈게요. 감사했습니다

    답글삭제
    답글
    1. 지원아, 잘 지내지?

      다른 공부할 것도 많은 시기인데 시간내서 프로그래밍을 계속 공부해오고 있다니 대단하구나.

      시간나면 언제든 놀러와~

      삭제

댓글 쓰기

이 블로그의 인기 게시물

Qt Designer 설치하기

PyQt5 기반 동영상 플레이어앱 만들기