Tuesday | 05 NOV 2024
[ previous ]
[ next ]

A Mail Command in Python - A Command Line App

Title:
Date: 2022-04-16
Tags:  

We're going to write a tiny mail utility in python. The goal is to mimic the mail command with the same set of flags. The reason was that trying to get the mail command to handle html was a pain in the ass and involved messing with the mail headers manually. It would be much better to use python and have it do the header manipulation. I however also don't want to re-write any of the existing scripts so we want to keep the same exact interface!

The first step is to figure out which flags we'll need.

Most of my mail commands look like this:

> echo "Body" | mail -s "Subject" -c cc@address.com -a /path/to/file/Intrcall.pdf -r from@address.com to@address.com

I pipe the e-mail body to the mail command and then set the subject, any cc addresses, add attachments, set the from address and finally set the destination.

The goal is to write a python version that could read in the above command and do the same thing as the mail command.

Let's get started!

A Mail Command in Python - argparse

Python has a builtin library to handle command line arguments that works quite well. There is quite a bit of magic in this library which I don't like but the results speak for themselves. You can get a pretty command line program up and running very easily with python.

The first snippet of code to run is this:

#!/usr/bin/env python3

import argparse

def main():
    parser = argparse.ArgumentParser()
    args = parser.parse_args()

main()

You can save this without the .py extension as this will be an executable we run. I named the program pymail. (Very creative).

I have also set the shebang(#!) to reference python3 using the env. This is because I don't know where this script will run and which version of python I might have or where it would be. This makes it so that whatever is linked python3 will be used to run this script.

Now we need to mark the program as executable:

> chmod +x pymail

Now you can execute the program:

> ./pymail
>

Voila! We get nothing! A bit underwhelming but! so much magic has happened.

Try the following:

> ./pymail -h

You should see the below screen. By using the argparse library we get some pretty help text and it sets up the first flag for us.

usage: pymail [-h]

optional arguments:
  -h, --help  show this help message and exit

Let's add a more descriptive help text.

    parser = argparse.ArgumentParser(description="Mail replacement in python.")

This will now print our description when someone looks for help.

> ./pymail -h
usage: pymail [-h]

Mail replacement in python.

optional arguments:
  -h, --help  show this help message and exit

We can also add an epilog to print some text after all of our flags as well.

    parser = argparse.ArgumentParser(description="Mail replacement in python", epilog="* not a meal replacement *")

This would print out:

> ./pymail -h
usage: pymail [-h]

Mail replacement in python.

optional arguments:
  -h, --help  show this help message and exit

* not a meal replacement *

Now we have argparse set up and ready to be used.

Let's move to the next step!

A Mail Command in Python - Adding Flags

Now that we have the core of our little program, we can now add the various flags we mentioned at the beginning of the previous chapter. We'll be adding optional flags, required flags, flags that can be duplicated, and finally a positional argument. We have quite a bit to get through!

The first flag we'll add is the flag that started this entire adventure. The HTML flag!

#!/usr/bin/env python3

import argparse

def main():
    parser = argparse.ArgumentParser(description="Mail replacement in python")

    parser.add_argument("-html","--html-flag", help="Set e-mail content type to html", action="store_true")

    args = parser.parse_args()
    print(args)

main()

We add an argument to the parser flag and we can specify a few different things. We can specify the short form of our command and we can specify the long form. The convention is that a short form uses a single dash whereas the long form of a command uses two dashes.

Side note, I use short forms on the command line but for scripts, I use long forms. This way it's obvious what flags the script is using.

Once we defined the flags, we set the help text. This will display when the user runs the help for our application.

The action is a big of magic. The store_true string is telling argparse that this flag is a boolean, it won't be followed by any text. This means that the existence of the html flag in the command means the flag is True. If the flag isn't in the command then the html_flag variable will be False.

Now you might be wondering why I said html_flag. Good question. More magic! It looks like argparse uses the flag names to infer the destination variable. This means --html-flag will become html_flag. We can also add a dest manually as well.

    parser.add_argument("-html","--html-flag", help="Set e-mail content type to html", action="store_true", dest="htmlFlag")

This would but the value of this flag in the variable htmlFlag.

The inferred names are all pretty good so we'll be using them going forward.

Now let's run our application with our new html flag.

> ./pymail -html
Namespace(html_flag=True)

We can see that args contains a object with our flag and it's value.

We can also run out command without the html flag:

> ./pymail
Namespace(html_flag=False)

Perfect! This exactly how we want our flag to work, the presence of the flag should give us a true and the absence of it should be false.

Now let's add the subject line as a flag.

    parser.add_argument("-html","--html-flag", help="Set e-mail content type to html", action="store_true")
    parser.add_argument("-s","--subject", help="Specify subject on command line", default="")

Once again we define a short form and long form of our flag. If we had just the short form, we would set the dest keyword to assign the flag to a variable.Here we introduce a new keyword, default. This let's us, as the name says, set the default for this flag. We could remove the default but then we will need to manually check the subject variable to see if it has anything in it.

> ./pymail
Namespace(html_flag=False, subject='')
> ./pymail -s "A subject line"
Namespace(html_flag=False, subject='A subject line')

So far our flags have been optional, the next flag we'll add is the from address and we want this to be required.

    parser.add_argument("-s","--subject", help="Specify subject on command line", default="")
    parser.add_argument("-r", "--from-address", help="Sets  the  From  address.", required=True)

This is also pretty straightforward, the only new thing is we have a required keyword that we can set to true.

> ./pymail
usage: pymail [-h] [-html] [-s SUBJECT] -r FROM_ADDRESS
pymail: error: the following arguments are required: -r/--from-address

Now our command line utility will throw an error if the from address is missing.

This is great!

> ./pymail s "A body" -r nivethant@example.com
Namespace(from_address='nivethant@example.com', html_flag=False, subject='A body')

The next flag we'll add is the flag to add cc addresses. Here we want to be able to specify multiple cc addresses. We can do this two ways, we can set up our flag so it takes multiple arguments but a single -c or we can have multiple -c flags for each cc address we want.

The second option is what I'll be using as that is what the mail command does.

    parser.add_argument("-r", "--from-address", help="Sets  the  From  address.", required=True)
    parser.add_argument("-c", "--cc-address", help="Send carbon copies to user.", action="append", default=[])

The cc flag has it's action as append. This means that all of the cc flags will be gathered together into a single list. We also set the default to be an empty list as this way, like the subject, we won't have to worry about making sure the variable is usable.

Now we are starting to get a program that can handle quite a few different things!

> ./pymail -s "A body" -c test@example.com -c another@example.com -r nivethant@example.com
Namespace(cc_address=['test@example.com', 'another@example.com'], from_address='nivethant@example.com', html_flag=False, subject='A body')

The next flag to add is the attachments. This will be the same as the cc.

    parser.add_argument("-c", "--cc-address", help="Send carbon copies to user.", action="append", default=[])
    parser.add_argument("-a", "--attachment", help="Attach the given file to the message.", action="append", default=[])

With that, we are now at the final thing we need our program to read in from the command line. This is the to address. The to address will be a positional argument and positional arguments are required.

    parser.add_argument("-a", "--attachment", help="Attach the given file to the message.", action="append", default=[])
    parser.add_argument("to_address", help="Specify the to address")

This has a big of magic as the dashes is what signifies if an argument is a flag or if it is a positional argument. The to_address here is a positional argument.

Now if we try to run an earlier command:

> ./pymail -s "A body" -c test@example.com -c another@example.com -r nivethant@example.com
usage: pymail [-h] [-html] [-s SUBJECT] -r FROM_ADDRESS [-c CC_ADDRESS]
              [-a ATTACHMENT]
            to_address
pymail: error: the following arguments are required: to_address

We need to give a destination e-mail address.

> ./pymail -s "A body" -c test@example.com -c another@example.com -r nivethant@example.com nivethan@test.com
Namespace(attachment=[], cc_address=['test@example.com', 'another@example.com'], from_address='nivethant@example.com', html_flag=False, subject='A body', to_address='nivethan@test.com')

Almost done! I also would like to send e-mails to multiple people at the same time. Let's set up the to as both a positional argument and also a flag.

This is a simple change as we just need to specify a flag for the to and set the action to append for both the flag "to" and the positional argument "to".

    parser.add_argument("-a", "--attachment", help="Attach the given file to the message.", action="append", default=[])
    parser.add_argument("-t", "--to-address", help="Specify multiple to addresses", action="append")
    parser.add_argument("to_address", help="Specify the to address", action="append")

Now we can have multiple to addresses and also specify a to with a flag.

> ./pymail -s "A body" -c test@example.com -c another@example.com -r nivethant@example.com -t another_to@example.com niv
ethan@test.com
Namespace(attachment=[], cc_address=['test@example.com', 'another@example.com'], from_address='nivethant@example.com', html_flag=False, subject='A body', to_address=['another_to@example.com', 'nivethan@test.com'])

Voila! With that we are done! We have a number of flags that we can now use when we write the mail portion of our command line application.

This is the full code below that we have so far:

#!/usr/bin/env python3

import argparse

def main():
    parser = argparse.ArgumentParser(description="Mail replacement in python")

    parser.add_argument("-html","--html-flag", help="Set e-mail content type to html", action="store_true")
    parser.add_argument("-s","--subject", help="Specify subject on command line", default="")
    parser.add_argument("-r", "--from-address", help="Sets  the  From  address.", required=True)
    parser.add_argument("-c", "--cc-address", help="Send carbon copies to user.", action="append", default=[])
    parser.add_argument("-a", "--attachment", help="Attach the given file to the message.", action="append", default=[])
    parser.add_argument("-t", "--to-address", help="Specify multiple to addresses", action="append")
    parser.add_argument("to_address", help="Specify the to address", action="append")

    args = parser.parse_args()
    print(args)

main()

It's not much but it is pretty powerful.

In the next chapter we'll add the e-mail body logic and format the data for our mail function.

A Mail Command in Python - Standard Input

Welcome back! At this point we have our command line flags all ready to go. The next thing we need is to be able either pipe in input from stdin or enter it in manually. Both of these things are the same thankfully.

Python has an input function that we can use like:

anything = input()
print(anything)

This will take in one line of text. However we want to feed in any number of lines into our program. To do this, we can read from stdin from python through the sys module.

#!/usr/bin/env python3
import argparse
import sys

def main():
    ...
    args = parser.parse_args()

    body = []
    for line in sys.stdin:
        body.append(line)
    body = "".join(body)

    print(args)
    print(body)

Here we will loop through the standard input until a ctrl D. The ctrl D is a character that will trigger a flush of the input and will let our program continue.

We read each line into an array and then join it together. You should notice that we delimit our array with a null string, this is because the new line characters will be part of input already, so don't need to add it in manually.

Now we can do the following:

> ./pymail -s "A body" -c test@example.com -c another@example.com -r nivethant@example.com -t another_to@example.com nivethan@test.com
Hi!
Namespace(attachment=[], cc_address=['test@example.com', 'another@example.com'], from_address='nivethant@example.com', html_flag=False, subject='A body', to_address=['another_to@example.com', 'nivethan@test.com'])
Hi!

This let's us supply a body where we run out python mail application. We use the ctrl D to finish our input.

We can also run our program by piping in the body:

> echo "Hi!" | ./pymail -s "A body" -c test@example.com -c another@example.com -r nivethant@example.com -t another_to@ex
ample.com nivethan@test.com
Namespace(attachment=[], cc_address=['test@example.com', 'another@example.com'], from_address='nivethant@example.com', html_flag=False, subject='A body', to_address=['another_to@example.com', 'nivethan@test.com'])
Hi!

Voila! We can now have an e-mail body. The next step is to write our mail function. Once we do that we can glue our main function which is our command processor and our mail function which handles the actual mailing of things.

Onwards!

A Mail Command in Python - The Mail Function

Now that we have the body ready to go, it's time to write our mail function! For now we will stub in our mail function.

def main():
    ...
    print(args)
    print(body)

    from_addr = "from@example.com"
    to_addr = "to@example.com"
    cc_addr = "cc@example.com"
    html_flag = True
    subject = "Test Subject"
    body = "Test Body"
    attachments = ["./test.txt", "./test.pdf"]

    send_email(from_addr, to_addr, cc_addr, html_flag, subject, body, attachments)

Here we have hardcoded all of the parameters we want out mail function to handle. These correspond to the flags that we have created but for now it'll be easier to debug if you can just dummy it up.

You will need to replace these values with real e-mail addresses and you'll also need a mail server set up as well.

https://nivethan.dev/devlog/setting-up-postfix-to-use-gmail.html

You can use the above guide to set up postfix to mail through gmail or office365.