You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Python icalendar发送Outlook邀请仅首个收件人收到,如何修复?

问题

我有一个定期运行的Python脚本,用于向所有收件人发送邮件邀请,告知即将到来的维护工作。测试时发现,仅receivers列表中的第一个收件人能收到Outlook邀请,其余收件人无法正常收到。以下是当前代码:

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 = 'sender@test.com'
        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 = ['test1@test.com', 'test2@test.com', 'test3@test.com']
    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()
解决方案

问题核心在于SMTP发送时的收件人参数处理错误,同时邮件头格式可以更规范,以下是两处修改:

1. 修复SMTP发送的收件人列表

原代码中sendmail方法的第二个参数传入了[msg["To"]],这会把拼接后的分号分隔字符串当成单个收件人,导致SMTP服务器只识别第一个地址。需要直接传入原始的self.receivers列表:

# 原代码
# self.smtp.sendmail(msg["From"], [msg["To"]], msg.as_string())

# 修改后
self.smtp.sendmail(msg["From"], self.receivers, msg.as_string())

2. 规范邮件头To字段格式(可选但推荐)

虽然Outlook支持分号分隔收件人,但标准邮件协议RFC推荐使用逗号分隔,修改__prepare_email_message中的To字段拼接方式:

# 原代码
# msg['To'] = ';'.join(self.receivers)

# 修改后
msg['To'] = ','.join(self.receivers)

修改后的完整代码

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 = 'sender@test.com'
        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"], self.receivers, msg.as_string())
        self.smtp.quit()
        print('Invitation sent out')


def main():
    receivers = ['test1@test.com', 'test2@test.com', 'test3@test.com']
    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()

内容的提问来源于stack exchange,提问作者mayer

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.31 23:55:21