- Products
- Solutions Use casesBy industry
- Developers
- Resources Connect
- Pricing
We have talked about sending and reading emails using Python previously. We even created an Email Client using JRuby, and Shoes. Why create an Email Client in the terminal? Well, thanks to Textual, creating excellent terminal applications is not a hard thing to do anymore.
Continue with the blog if you already have the Nylas Python SDK installed and your Python environment configured.
Otherwise, I recommend reading the post How to Send Emails with the Nylas Python SDK where the basic setup is clearly explained.
When we think about terminal applications, we can only think about white text on a black screen, which might be true for applications not built using Textual. With Textual, we can have buttons, trees, data tables, radio buttons and much more.
We will build a Terminal Email Client using Textual:
Here, we grab the first 5 emails of our inbox. The space below the list of emails is where we will load each email to read:
If you check the footer, we can refresh (R), delete (D), compose (O) or reply (P), by pressing the letter accompanying each command. Let’s try the Compose Email one:
Textual doesn’t have a multiline input component, so we’re doing our best here. We can send the email by either pressing the button or hitting the letter S.
We can check if the email was actually sent:
Now, let’s try reply:
After we select an email, we can hit P and start replying to the email:
We can check the reply:
We can test Refresh and Delete, but they might not make too much sense in images, so let’s leave them for now.
While there are a lot of imports in the source code, we only need to install a couple of packages, if we don’t have installed already:
$ pip3 install textual[dev] → The Textual package $ pip3 install beautifulsoup4 → HTML and XML parser
For our Textual terminal client, we’re going to create a folder called terminal_email_client and inside we’re going to create a Python script called email_client.py with the following code:
Our application will be separated using screens, so let’s start with the main screen first:
# Import your dependencies from dotenv import load_dotenv import os from rich.text import Text from nylas import APIClient # type: ignore from textual.app import App, ComposeResult from textual.containers import Horizontal from textual.widgets import DataTable, Label, Header, Footer, Input, Button from textual.screen import Screen from textual.binding import Binding import datetime from bs4 import BeautifulSoup import textwrap from typing import List, Any # Load your env variables load_dotenv() # Initialize an instance of the Nylas SDK using the client credentials nylas = APIClient( os.environ.get("CLIENT_ID"), os.environ.get("CLIENT_SECRET"), os.environ.get("ACCESS_TOKEN"), ) # Create the header of the Data Table ROWS = [("Date", "Subject", "From", "Unread")] # Global variables messageid = [] labelsDict = {} labels = nylas.labels.all() for label in labels: labelsDict[label["name"]] = label["id"] # Get the body of a particular message clean of HTML tags def get_message(self, message_id: str) -> str: body = "" message = nylas.messages.get(message_id) soup = BeautifulSoup(message.body, "html.parser") for data in soup(["style", "script"]): data.decompose() wrapper = textwrap.TextWrapper(width=75) word_list = wrapper.wrap(text=" ".join(soup.stripped_strings)) body = "" for word in word_list: body = body + word + "\n" if self != None: try: message.mark_as_read() message.save() except Exception: if message.unread == True: self.populate_table() self.populate_table() return body # Read the first 5 messages of our inbox def get_messages() -> List[Any]: messages = nylas.messages.where(in_="inbox", limit=5) ROWS.clear() ROWS.append(("Date", "Subject", "From", "Unread")) for message in messages: _from = message.from_[0]["name"] + " / " + message.from_[0]["email"] ROWS.append( ( datetime.datetime.fromtimestamp(message.date).strftime( "%Y-%m-%d %H:%M:%S" ), message.subject[0:50], _from, message.unread, ) ) return messages
This will load up our mailbox, and will allow us to read emails, delete, compose and reply to emails:
# This can be considered the main screen class EmailApp(App): # Setup the bindings for the footer BINDINGS = [ Binding("r", "refresh", "Refresh"), Binding("s", "send", "Send", show=False), Binding("c", "cancel", "Cancel", show=False), Binding("d", "delete", "Delete"), Binding("o", "compose", "Compose Email"), Binding("p", "reply", "Reply"), ] # Class variables messages = [Any] id_message = 0 # Fill up the Data table def populate_table(self) -> None: self.messages = get_messages() table = self.query_one(DataTable) table.clear() table.cursor_type = "row" rows = iter(ROWS) counter = 0 for row in rows: if counter > 0: if row[3] is True: styled_row = [ Text(str(cell), style="bold #03AC13") for cell in row ] table.add_row(*styled_row) else: table.add_row(*row) counter += 1 # Load up the main components of the screen def compose(self) -> ComposeResult: yield Header() yield Footer() yield DataTable() yield Label(id="message") # After we load the components, fill up their data def on_mount(self) -> None: self.messages = get_messages() table = self.query_one(DataTable) table.cursor_type = "row" rows = iter(ROWS) table.add_columns(*next(rows)) for row in rows: if row[3] is True: styled_row = [ Text(str(cell), style="bold #03AC13") for cell in row ] table.add_row(*styled_row) else: table.add_row(*row) # When we select a line on our Data table, or read # an email def on_data_table_row_selected(self, event) -> None: message = self.query_one("#message", Label) self.id_message = self.messages[event.cursor_row].id messageid.clear() messageid.append(self.id_message) message.update(get_message(self, self.id_message)) # We're deleting an email def action_delete(self) -> None: try: _message = nylas.messages.get(self.id_message) _message.add_label(labelsDict["trash"]) _message.save() except Exception as e: pass self.populate_table() # We want to Compose a new email def action_compose(self) -> None: self.push_screen(ComposeEmail()) # We want to refresh by calling in new emails def action_refresh(self) -> None: self.populate_table() # We want to reply to an email def action_reply(self) -> None: if len(messageid) > 0: self.push_screen(ReplyScreen())
This is the reply screen, and it will be called when we read an email and we want to create a response or reply:
# Reply screen. This screen we will be displayed when we are # replying an email class ReplyScreen(Screen): # Setup the bindings for the footer BINDINGS = [ Binding("r", "refresh", "Refresh", show=False), Binding("s", "send", "Send"), Binding("c", "cancel", "Cancel"), Binding("d", "delete", "Delete", show=False), Binding("o", "compose", "Compose Email", show=False), Binding("p", "reply", "Reply", show=False), ] # Load up the main components of the screen def compose(self) -> ComposeResult: yield Header() yield Footer() yield Input(id="email_from") yield Input(id="title") yield Label(id="body") yield Label("======================") yield Input(id="first") yield Input(id="second") yield Input(id="third") yield Input(id="fourth") yield Horizontal( Button("Send!", variant="primary", id="send"), Label(" "), Button("Cancel", variant="primary", id="cancel"), ) # After we load the components, fill up their data def on_mount(self) -> None: message = nylas.messages.get(messageid[0]) body = self.query_one("#body", Label) self.query_one("#email_from").value = message.from_[0]["email"] self.query_one("#title").value = "Re: " + message.subject body.update(get_message(None, messageid[0])) # Grab the information and send the reply to the email def send_email(self) -> None: participants = [] draft = nylas.drafts.create() list_of_emails = self.query_one("#email_from").value.split(";") draft.subject = self.query_one("#title").value draft.body = ( self.query_one("#first").value + "\n" + self.query_one("#second").value + "\n" + self.query_one("#third").value + "\n" + self.query_one("#fourth").value ) for i in range(0, len(list_of_emails)): participants.append({"name": "", "email": list_of_emails[i]}) draft.to = participants draft.reply_to_message_id = messageid[0] try: draft.send() self.query_one("#email_from").value = "" self.query_one("#title").value = "" self.query_one("#first").value = "" self.query_one("#second").value = "" self.query_one("#third").value = "" self.query_one("#fourth").value = "" messageid.clear() participants.clear() app.pop_screen() except Exception as e: print(e.message) # This commands should not work on this screen def action_delete(self) -> None: pass def action_compose(self) -> None: pass def action_refresh(self) -> None: pass def action_reply(self) -> None: pass # We pressing a key def action_cancel(self) -> None: app.pop_screen() def action_send(self) -> None: self.send_email() # We're pressing a button def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "send": self.send_email() elif event.button.id == "cancel": app.pop_screen()
This is the compose screen that is called when we want to create or compose a new email:
# Compose screen. This screen we will be displayed when we are # creating or composing a new email class ComposeEmail(Screen): # Setup the bindings for the footer BINDINGS = [ Binding("r", "refresh", "Refresh", show=False), Binding("s", "send", "Send"), Binding("c", "cancel", "Cancel"), Binding("d", "delete", "Delete", show=False), Binding("o", "compose", "Compose Email", show=False), Binding("p", "reply", "Reply", show=False), ] # Load up the main components of the screen def compose(self) -> ComposeResult: yield Header() yield Footer() yield Input(placeholder="Email To", id="email_to") yield Input(placeholder="Title", id="title") yield Label("======================") yield Input(id="first") yield Input(id="second") yield Input(id="third") yield Input(id="fourth") yield Horizontal( Button("Send!", variant="primary", id="send"), Label(" "), Button("Cancel", variant="primary", id="cancel"), ) # Grab the information and send the email def send_email(self) -> None: participants = [] draft = nylas.drafts.create() list_of_emails = self.query_one("#email_to").value.split(";") draft.subject = self.query_one("#title").value draft.body = ( self.query_one("#first").value + "\n" + self.query_one("#second").value + "\n" + self.query_one("#third").value + "\n" + self.query_one("#fourth").value ) for i in range(0, len(list_of_emails)): participants.append({"name": "", "email": list_of_emails[i]}) draft.to = participants try: draft.send() self.query_one("#email_to").value = "" self.query_one("#title").value = "" self.query_one("#first").value = "" self.query_one("#second").value = "" self.query_one("#third").value = "" self.query_one("#fourth").value = "" participants.clear() app.pop_screen() except Exception as e: print(e.message) # This commands should not work on this screen def action_delete(self) -> None: pass def action_compose(self) -> None: pass def action_refresh(self) -> None: pass def action_reply(self) -> None: pass # We pressing a key def action_cancel(self) -> None: app.pop_screen() def action_send(self) -> None: self.send_email() # We pressing a button def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "send": self.send_email() elif event.button.id == "cancel": app.pop_screen()
And last but not least, we need to pass the main class or screen and instruct our application to run:
# Pass the main class and run the application if __name__ == "__main__": app = EmailApp() app.run()
The only thing we need to do is to run our script like this:
$ python3 email_client.py
And our terminal email client built with Textual will show up displaying our inbox.
If you want to learn more about Emails, go to our documentation. Also, don’t miss the action, join our LiveStream Coding with Nylas:
Blag aka Alvaro Tejada Galindo is a Senior Developer Advocate at Nylas. He loves learning about programming and sharing knowledge with the community. When he’s not coding, he’s spending time with his wife, daughter and son. He loves Punk Music and reading all sorts of books.