spaeter – a partly vibed imap based very simple mail scheduler written in python

by Ulf Schleth

a vibed image for a vibed mail scheduler

a vibed image for „spaeter“, a vibed mail scheduler

preface

hi. this time in intentional lowercase.

since ai is not good in programming, i started to vibe some little tools with my ai colleagues so they cant do wrong so much. nevertheless, vibing is work. like modeling an ashtray out of clay. or a statue out of soapstone. in this case, maybe more an ugly little cup. lets start with this: i cant count how often i was annoyed by the fact there is no standardized way to send a mail scheduled.

you know that feeling when you would like to be able to send yourself a reminder at a certain time via email, because your email awareness is much higher than your awareness for anything else? or its friday afternoon and you know when you write your email now, nobody would read it, but if you would send it on monday forenoon that would change? or you would like to send a mail to your colleague right after his holiday, because you know he is such a neoliberal stupid that he cant resist reading it in his holiday because he has no life and you want to help him get one?

i dont need that often, but if i need it, i need it urgently. i know thunderbird has a plugin which can do this but that only runs when thunderbird is running. and i dont use thunderbird. and i am changing workplaces all the time. since i used a nextcloud, i thought it would be a great thing to put something like that into it.

therefore, i filed a feature request for that. and you wont believe it, some years later they did start to work on that request. but after they finished – how disappointed i was when i noticed that i wouldnt use it, because when you install the nextcloud mail client you are not able to use it just to send mails. it forces you to receive your mails and does a lot of things to your email you dont want, like marking it as important or whatever.

spaeter

now, i decided to do a very simple solution called spaeter which means later in german. if you want to send a mail to a certain time you have to write the send time at the beginning of your subject like this:

2125-07-24 14:00 BUH! A mail by your dead grandfather

and save it to a configured imap folder. in evolution i do this by saving my mail to the drafts folder and moving it from there to the scheduling folder which i called spaeter. thats it. and the best is: it works for any client. to be fair, for any client which allows you to save a message into your schedule folder. you could even use your drafts folder for it if you are courageous enough. when the time has come, the email will be sent (without the time in the subject) and, if thats possible and if sent_folder is configured in the configuration file, moved to the sent folder (with the time).

you can configure as many accounts as you like but there is a catch: you have to store your passwords in plaintext. at least do a chmod 600 on the configuration file. if you find a way to encrypt the password in a simple way which isnt based on snakeoil (=“security by obscurity“), please leave a note in the comments.

requirements: you need some basic skills (since i dont explain everything in detail) and a computer to run this script.
that computer can be a server – whether it is a real server or just your own raspberry pi does not matter. the important thing is that the host running this script needs to be running continuously if you want to use it in real time. of course, you could also install it on your laptop; the script will still send out scheduled emails even after their planned time if it was not running earlier. you also need python and some modules. run: pip3 install –user pyyaml pytz and do the same for any other python components that might be missing.

installation: just copy both files into the same folder. execute the python script spaeter.py. edit the config file and save it as spaeter-config.yml – you dont have to fill out sent_folder – you wont get a sent copy then. so do it. and you can add account blocks or delete them if you for example need only one. try it out on the command line until it works. then do a crontab -e and add a line like this:

*/5 * * * * /usr/bin/python3 /home/user/bin/spaeter.py >> /dev/null 2>&1

you may also run it continuously in daemon mode, you just have to uncomment 2 lines, have a look at the end of the file.

finally

here is the source code. save it as spaeter.py – have fun:

#!/usr/bin/env python3

#
# spaeter - an imap based mail scheduler, come and viisit:
# https://www.schleth.com/tooltips/spaeter-a-partly-vibed-imap-based-very-simple-mail-scheduler-written-in-python-2605.html
#

import imaplib, smtplib, email, os
from email.message import EmailMessage
import yaml
import time
import ssl
import pytz
from datetime import datetime
from email.header import decode_header

# the config file should be in the same directory as this script
CONFIG_FILE = 'spaeter-config.yml'

# Decodes MIME-encoded words in an email header string to readable text
def decode_mime_words(s):
    if s is None:
        return ""
    decoded = decode_header(s)
    return ''.join([str(t[0], t[1] or 'utf-8') if isinstance(t[0], bytes) else str(t[0]) for t in decoded])

# Extracts the scheduled send time from the start of the subject and returns it (in UTC) along with the cleaned subject
# If extraction fails, returns None and the original subject
def extract_send_time_and_clean_subject(subject):
    try:
        send_time_str = subject[:16]
        send_time = TZ.localize(datetime.strptime(send_time_str, "%Y-%m-%d %H:%M")).astimezone(pytz.utc)
        cleaned_subject = subject[16:].strip()
        return send_time, cleaned_subject
    except Exception:
        return None, subject

# Extracts and cleans the body of an email message
def extract_clean_body(msg):
    if msg.is_multipart():
        for part in msg.walk():
            if part.get_content_type() == "text/plain":
                return part.get_payload(decode=True).decode(part.get_content_charset() or 'utf-8').strip()
    else:
        return msg.get_payload(decode=True).decode(msg.get_content_charset() or 'utf-8').strip()
    return ""

# Processes a single email account: checks for scheduled emails, and sends them if the scheduled time has come
def process_account(account):
    now_utc = datetime.utcnow().replace(tzinfo=pytz.utc)
    try:
        mail = imaplib.IMAP4_SSL(account['imap']['server'])
        mail.login(account['imap']['user'], account['imap']['pass'])
        mail.select(account['imap']['folder'])

        typ, data = mail.search(None, 'ALL')
        for num in data[0].split():
            typ, msg_data = mail.fetch(num, '(RFC822)')
            msg = email.message_from_bytes(msg_data[0][1])

            send_time, cleaned_subject = extract_send_time_and_clean_subject(decode_mime_words(msg["Subject"]))
            if not send_time or now_utc < send_time:
                continue

            print(f"[{account['imap']['user']}] Send mail as planned at {send_time.astimezone(TZ)}")

            forward = EmailMessage()
            forward["From"] = msg["From"]
            forward["To"] = msg["To"]
            forward["Subject"] = cleaned_subject

            cleaned_body = extract_clean_body(msg)
            forward.set_content(cleaned_body)

            context = ssl.create_default_context()
            sent_success = False
            try:
                with smtplib.SMTP(account['smtp']['server'], 587) as s:
                    s.starttls(context=context)
                    s.login(account['smtp']['user'], account['smtp']['pass'])
                    s.send_message(forward)
                sent_success = True
            except Exception as send_err:
                print(f"Failed to send mail: {send_err}")

            if sent_success:
                sent_folder = account['imap'].get('sent_folder', '').strip()
                if sent_folder:
                    # Copy to sent folder and mark as deleted in current folder
                    # uses imap & keeps the send-time in the subject for traceability and simplicity
                    copy_result = mail.copy(num, sent_folder)
                    if copy_result[0] == 'OK':
                        mail.store(num, '+FLAGS', '\\Deleted')
                    else:
                        print(f"Failed to move mail to sent folder: {sent_folder}")
                else:
                    mail.store(num, '+FLAGS', '\\Deleted')

        mail.expunge()
        mail.logout()
    except Exception as e:
        print(f"Error with account {account['imap']['user']}: {e}")

def get_timezone_from_config(config):
    """
    Returns the pytz timezone object from the loaded config dict.
    """
    tzname = config.get('timezone', 'Europe/Berlin')
    return pytz.timezone(tzname)

# Main function: loads configuration and processes each account
def main():
    script_dir = os.path.dirname(os.path.realpath(__file__))
    config_path = os.path.join(script_dir, CONFIG_FILE)

    with open(config_path, 'r') as f:
        config = yaml.safe_load(f)

    global TZ
    TZ = get_timezone_from_config(config)

    # UNCOMMENT THE FOLLOWING LINE AND ADJUST IINDENTING TO ENABLE CONTINUOUS PROCESSING
    #while True:
    for account in config['accounts']:
        process_account(account)
    # UNCOMMENT THE FOLLOWING LINE AND ADJUST IINDENTING TO ENABLE CONTINUOUS PROCESSING
    #    time.sleep(config.get('interval_seconds', 60))

if __name__ == '__main__':
    main()

 

and finally, here is the config file, save it as spaeter-config.yml and change it so it fits your needs:

timezone: Europe/Berlin
interval_seconds: 60 #ignore this unless you use daemon mode
accounts:
  - imap:
      server: imap1.mailfirma.de
      user: user1
      pass: deinpass1
      folder: spaeter
      sent_folder: Sent
    smtp:
      server: smtp.mailfirma.de
      user: user1
      pass: deinpass1

  - imap:
      server: imap2.mailfirma.de
      user: user2@firma.de
      pass: deinpass2
      folder: spaeter
      sent_folder: Sent
    smtp:
      server: smtp.mailfirma.de
      user: user2@firma.de
      pass: deinpass2

 

Dieser Text gefällt Dir?

Kommentar schreiben

Die E-Mail-Adresse wird nicht angezeigt. Felder mit * müssen ausgefüllt werden.

*

*