Why only the first one can get the email invitation via Python icalendar

Question:

I have a script that will run periodically to send email invitations to all receivers to inform them about upcoming maintenance.
Here is the code example

import os
import uuid
import smtplib
import icalendar
import datetime

from email.mime.base import MIMEBase
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email import encoders

import pytz
from jinja2 import FileSystemLoader, Environment


class EmailWriter:
    SMTP = 'smtp.test.com'

    def __init__(self, receivers, cluster_name, dtstart=None, dtend=None, available="", tasks=""):
        self.sender = '[email protected]'
        self.smtp = smtplib.SMTP(EmailWriter.SMTP)

        self.receivers = receivers
        self.cluster_name = cluster_name
        self.dtstart = dtstart
        self.dtend = dtend
        self.available = available
        self.tasks = tasks

    def __get_email_subject_and_content(self):
        path = os.path.join(os.getcwd(), 'email_templates')
        loader = FileSystemLoader(path)
        env = Environment(loader=loader)
        template_minor = env.get_template('minor_maintenance_email.html')
        template_major = env.get_template('major_maintenance_email.html')
        if 'unavailability' in self.available.lower():
            html_content = template_major.render(
                availability=self.available,
                maintenance_date=self.dtstart,
                start_time=self.dtstart,
                expected_end_time=self.dtend,
                tasks=self.tasks
            )
            subject = '{} | Maintenance | {}'.format(self.cluster_name, self.available)
        else:
            html_content = template_minor.render()
            subject = '{} | Maintenance | 100% Availability'.format(self.cluster_name)
        print('subject : "{}", receivers : "{}", maintenance_date : "{}", start_time : "{}", expected_end_time : "{}", '
              '"task : "{}"'.format(subject, self.receivers, self.dtstart, self.dtstart, self.dtend, self.tasks))
        return subject, html_content

    def __prepare_event(self, subject, content, start, end):
        event = icalendar.Event()
        organizer = icalendar.vCalAddress('MAILTO:' + self.sender)
        event.add('organizer', organizer)
        event.add('status', 'confirmed')
        event.add('category', 'Event')
        event.add('summary', subject)
        event.add('description', content)
        event.add('dtstart', start)
        event.add('dtend', end)
        event.add('dtstamp', datetime.datetime.now())
        event['uid'] = uuid.uuid4()
        # Set the busy status of the appointment to free
        event.add('X-MICROSOFT-CDO-BUSYSTATUS', icalendar.vText('FREE'))
        event.add('priority', 5)
        event.add('sequence', 0)
        event.add('created', datetime.datetime.now())
        for participant in self.receivers:
            attendee = icalendar.vCalAddress('MAILTO:' + participant)
            attendee.params['ROLE'] = icalendar.vText('REQ-PARTICIPANT')
            attendee.params['cn'] = icalendar.vText(' '.join(participant.split('@')[0].split('.')))
            event.add('attendee', attendee, encode=0)
        return event

    def __prepare_alarm(self):
        alarm = icalendar.Alarm()
        alarm.add('action', 'DISPLAY')
        alarm.add('description', 'Reminder')
        # The only way to convince Outlook to do it correctly
        alarm.add('TRIGGER;RELATED=START', '-PT{0}H'.format(1))
        return alarm

    def __prepare_icalendar(self):
        # Build the event itself
        cal = icalendar.Calendar()
        cal.add('prodid', icalendar.vText('-//Calendar Application//'))
        cal.add('version', icalendar.vInt(2.0))
        cal.add('method', icalendar.vText('REQUEST'))
        # creates one instance of the event
        cal.add('X-MS-OLK-FORCEINSPECTOROPEN', icalendar.vBoolean(True))
        return cal

    def __prepare_email_message(self, subject, content):
        # Build the email message
        # msg = MIMEMultipart('alternative')
        msg = MIMEMultipart('mixed')
        msg['Subject'] = subject
        msg['From'] = self.sender
        msg['To'] = ';'.join(self.receivers)
        msg['Content-class'] = 'urn:content-classes:calendarmessage'
        msg.attach(MIMEText(content, 'html', 'utf-8'))
        return msg

    def __prepare_invite_blocker(self, cal):
        filename = 'invite.ics'
        part = MIMEBase('text', 'calendar', method='REQUEST', name=filename)
        part.set_payload(cal.to_ical())
        encoders.encode_base64(part)
        part.add_header('Content-Description', filename)
        part.add_header('Filename', filename)
        part.add_header('Path', filename)
        return part

    def send_appointment(self):
        subject, html_content = self.__get_email_subject_and_content()

        start = datetime.datetime.combine(self.dtstart, datetime.time(0, 0, 0)).astimezone(pytz.UTC)
        end = datetime.datetime.combine(self.dtend, datetime.time(0, 0, 0)).astimezone(pytz.UTC)
        cal = self.__prepare_icalendar()
        event = self.__prepare_event(subject, html_content, start, end)
        alarm = self.__prepare_alarm()

        # Add a reminder
        event.add_component(alarm)
        cal.add_component(event)

        part = self.__prepare_invite_blocker(cal)
        msg = self.__prepare_email_message(subject, html_content)
        msg.attach(part)

        # Send the email out
        self.smtp.sendmail(msg["From"], [msg["To"]], msg.as_string())
        self.smtp.quit()
        print('Invitation sent out')


def main():
    receivers = ['[email protected]', '[email protected]', '[email protected]']
    cluster_name = 'TEST NOW (test_now)'  # test cluster name

    email_writer = EmailWriter(
        receivers,
        cluster_name,
        datetime.datetime.strptime('2023-02-16', '%Y-%m-%d').date(),
        datetime.datetime.strptime('2023-02-16', '%Y-%m-%d').date() + datetime.timedelta(days=1),
        '100% Availability',
        tasks='Minor test'
    )
    print('Sending email')
    email_writer.send_appointment()


if __name__ == '__main__':
    main()

However, when I tested the code, I could see only the first recipient in the receivers list can get the outlook invitation.
How to fix the code to let all email account in the list can get the invitation?

Asked By: mayer

||

Answers:

Looking at some other examples, it looks like the msg[‘To’] object needs to be in a string format with a delimiter of ‘,’ I believe you are using ‘;’ try changing that in your code and see if that resolves the issue.

current_code: msg[‘To’] = ‘;’.join(self.receivers)

new_code: msg[‘To’] = ‘, ‘.join(self.receivers)

Answered By: Nolan Walker

Thank you for your help!

Finally, I got a solution here.
First, as the above answer said, I need to use , as the delimiter.

msg['To'] = ', '.join(self.receivers)

Second, in the function of smtp.sendmail(), the receiver parameter type is a list, so here I directly give the function a list

self.smtp.sendmail(msg['From'], self.receivers, msg.as_string())

Source code of sendmail()

    def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
                 rcpt_options=()):
        """This command performs an entire mail transaction.

        The arguments are:
            - to_addrs     : A list of addresses to send this mail to.  A bare
                             string will be treated as a list with 1 address.

 Example:

         >>> import smtplib
         >>> s=smtplib.SMTP("localhost")
         >>> tolist=["[email protected]","[email protected]","[email protected]","[email protected]"]
         >>> msg = '''\
         ... From: [email protected]
         ... Subject: testin'...
         ...
         ... This is a test '''
         >>> s.sendmail("[email protected]",tolist,msg)
Answered By: mayer
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.