diff --git a/README.md b/README.md index 071ace9..4c28d6d 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,123 @@ -mutt-ical.py is meant as a simple way to display and reply to ical invitations from mutt. +This single-file Python script `mutt-ical.py` displays and replies to [Icalendar](https://tools.ietf.org/html/rfc5545) invitations (.ics files) from mutt. +It requires Mutt and Python with the [Vobject](https://github.com/py-vobject/vobject) Python package that can be installed with `pip install --user git+https://github.com/py-vobject/vobject`. -Warning -------- - -This script was written during a few days in 2013 for Python 2. I currently do -not use it myself. There might be still issues with recent versions of Python 3. +Installing +---------- -If this script does not work for you, please consider fixing it yourself. It -is just a few lines of code after all. +Copy the script into a folder in your `$PATH`, (or specify the `$PATH` in your `mailcap` and `muttrc` files). +Mark it executable by `chmod +x`. +We will assume that you copied it into `~/bin` so that its full path is `~/bin/mutt-ical.py`. -Pull requests are accepted, but please do not expect me to test them, they -will be merged after a quick and shallow review. +Initial Setup +------------- -You have been warned. +* To view the calendar entry in mutt by opening the calendar entry in the attachment view (usually opened by hitting `v` after having selected a mail in mutt), add to your `mailcap` file (found at '`~/.mailcap`, `/etc/mailcap` or `$mailcap_path` set in `muttrc`) the following lines: -Installing ----------- +```muttrc +text/calendar; ~/bin/mutt-ical.py -D %s; copiousoutput +text/x-vcalendar; ~/bin/mutt-ical.py -D %s; copiousoutput +application/ics; ~/bin/mutt-ical.py -D %s; copiousoutput +``` -* Copy it into somewhere in your PATH, (or you can specify the PATH in your .mailcap) -* Edit your mailcap (~/.mailcap or /etc/mailcap) to have the following line: +- To view the calendar entry by opening a selected a mail in mutt, add to your .muttrc the following lines: +```muttrc +auto_view text/calendar text/x-vcalendar application/ics text/plain text/html +alternative_order text/calendar text/x-vcalendar application/ics text/plain text/html ``` -# for replying -text/calendar; mutt-ical.py -i -e "user@domain.tld" %s -application/ics; mutt-ical.py -i -e "user@domain.tld" %s -# for auto-view -text/calendar; mutt-ical.py -D %s; copiousoutput -application/ics; mutt-ical.py -D %s; copiousoutput -``` -(Don't forget to add your email address) -* if you want to use auto view in the pager, use something like the following in .muttrc - (just an example, configure text/plain and text/html at your own discretion) +Here the second line ensures that the calendar entry is displayed before the message text. -``` -auto_view text/calendar text/plain text/html -alternative_order text/calendar text/plain text/html +- To answer a calendar entry by pressing `a` in the attachment view (usually opened by hitting `v` after having selected a mail in mutt), add to your .muttrc a line such as + +```muttrc +macro attach a \ + "iconv -c --to-code=UTF-8 > ~/.cache/mutt.ics~/bin/mutt-ical.py -i -e your.email@address -s 'msmtp --account=work' ~/.cache/mutt.ics" \ + "answer appointment request" ``` +Here you need to customize the email address `your.email@address` and the send command `msmtp --account=work`. +If `-s` is not set, the Mutt setting `$sendmail` will be used. -OSX Users ---------- +- To optionally open the Icalendar in your favorite desktop calendar application, such as `Ical`, run `xdg-open ~/.cache/mutt.ics` (on Linux, respectively `open ~/.cache/mutt.ics` on Mac OS) after the `mutt-ical.py` command, for example -* For added fun on OSX, you can extend it to the following, to get iCal to open it nicely too (iCal does not care for mime types it seems): -``` -text/calendar; mv %s %s.ics && open %s.ics && mutt-ical.py -i -e "user@domain.tld" %s.ics && rm %s.ics +```muttrc +macro attach a \ + "iconv -c --to-code=UTF-8 > ~/.cache/mutt.ics~/bin/mutt-ical.py -i -e your.email@address -s 'msmtp --account=work' ~/.cache/mutt.ics && xdg-open ~/.cache/mutt.ics" \ + "reply to appointment request" ``` -* You can force iCal to stop trying to send mail on your behalf by replacing -the file `/Applications/iCal.app/Contents/Resources/Scripts/Mail.scpt` with your -own ActionScript. I went with the following: `error number -128` -Which tells it that the user cancelled the action. - * Open AppleScript Editor, paste the code from above into a new script, then save it. - * Move the old script `/Applications/iCal.app/Contents/Resources/Scripts/Mail.scpt` just in case you want to re-enable the functionality. - * Copy your new script into place. Usage ----- -If you configure auto_view (see above), the description should be visible in +If you configure auto_view (as above), then the description should be visible in the pager. +(Otherwise view the attachements (usually by hitting 'v'), select and open the `Icalendar` entry.) +To answer, just open the `Icalendar` file from mutt: -To reply, just open the ical file from mutt: * View the attachements (usually 'v') * Select the text/calendar entry -* Invoke the mailcap entry (usually 'm') -* Choose your reply +* Hit 'a' +* Choose your answer + + +Documentation +------------- + +The script supports the following options: `-i`, `-a`, `-d`, `-t`, `-D`, and `-s`. The `-e` option followed by an email address and the path to an `.ics` file are required. +If the `-D` option is used, the script will display the event details and terminate without sending any replies. +The `-i`, `-a`, `-d`, and `-t` options are mutually exclusive; +the last one specified determines the response type sent. +By default, the script will use Mutt's sendmail setting to send the answer, unless the `-s` option is used to override it. + +```sh +-e + Specify the email address to send the answer from. This should be the email address that received the invitation. + +-i + Interactive mode. Prompt for user input to accept, decline, or tentatively accept the invitation. + +-a + Accept the invitation automatically without prompting. + +-d + Decline the invitation automatically without prompting. + +-t + Tentatively accept the invitation automatically without prompting. + +-D + Display only. Show the event details but do not send any answer. + +-s + Specify the sendmail command to use for sending the email. If not set, the default Mutt setting will be used. + +filename.ics + The .ics file to be processed. This should be the invitation file received. + +Usage: + + mutt-ical.py [OPTIONS] -e your@email.address filename.ics +``` + +Make sure to replace the placeholder `your.email@address` with your actual email address and `filename.ics` with the path to the .ics file when running the script. + +Hints +----- + +Mac OS X iCal Users can force iCal to stop trying to send mail on your behalf by replacing +the file `/Applications/iCal.app/Contents/Resources/Scripts/Mail.scpt` with your +own ActionScript. Martin Sander went with the following: `error number -128` +Which tells it that the user cancelled the action. -An old sendmail wrapper is also included, but since mutt's SMTP support has now improved, rather configure your SMTP settings through mutt's config. The wrapper is kept in case someone finds it useful. However, the get_mutt_command function will need the relevant code uncommented to use it. +* Open AppleScript Editor, paste the code from above into a new script, then save it. +* Move the old script `/Applications/iCal.app/Contents/Resources/Scripts/Mail.scpt` just in case you want to re-enable the functionality. +* Copy your new script into place. -Requirements +Inspiration ------------ -mutt, python, bash, vobject: -http://vobject.skyhouseconsulting.com/ or 'easy_install vobject' +This forks [Martin Sander's](https://github.com/marvinthepa/mutt-ical/) whose (MIT) license restrictions apply, and which was inspired by +[accept.py](http://weirdzone.ru/~veider/accept.py) and [Rubyforge.org Samples](http://vpim.rubyforge.org/files/samples/README_mutt.html). -Inspired by: -* http://weirdzone.ru/~veider/accept.py -* http://vpim.rubyforge.org/files/samples/README_mutt.html diff --git a/mutt-ical.py b/mutt-ical.py index 1979e83..f864a16 100755 --- a/mutt-ical.py +++ b/mutt-ical.py @@ -5,56 +5,63 @@ See README for instructions and LICENSE for licensing information. """ -__author__="Martin Sander" -__license__="MIT" +__author__ = "Martin Sander" +__license__ = "MIT" -import vobject -import tempfile, time -import os, sys -import warnings -from datetime import datetime +import locale import subprocess +import sys +import time +from datetime import datetime +from email.message import EmailMessage from getopt import gnu_getopt as getopt -from email.message import EmailMessage +import vobject -usage=""" +usage = """ usage: %s [OPTIONS] -e your@email.address filename.ics OPTIONS: -i interactive -a accept -d decline - -t tentatively accept - (accept is default, last one wins) + -t tentatively accept (accept is default, last one wins) -D display only + -s """ % sys.argv[0] + def del_if_present(dic, key): if key in dic: del dic[key] + def set_accept_state(attendees, state): for attendee in attendees: attendee.params['PARTSTAT'] = [state] - for i in ["RSVP","ROLE","X-NUM-GUESTS","CUTYPE"]: - del_if_present(attendee.params,i) + for i in ["RSVP", "ROLE", "X-NUM-GUESTS", "CUTYPE"]: + # del_if_present(attendee.params, i) + def del_if_present(dic: dict, key: str) -> None: + if key in dic: + del dic[key] return attendees + def get_accept_decline(): while True: sys.stdout.write("\nAccept Invitation? [Y]es/[n]o/[t]entative/[c]ancel\n") ans = sys.stdin.readline() if ans.lower() == 'y\n' or ans == '\n': return 'ACCEPTED' - elif ans.lower() == 'n\n': + if ans.lower() == 'n\n': return 'DECLINED' - elif ans.lower() =='t\n': + if ans.lower() == 't\n': return 'TENTATIVE' - elif ans.lower() =='c\n': + if ans.lower() == 'c\n': print("aborted") sys.exit(1) + def get_answer(invitation): # create ans = vobject.newFromBehavior('vcalendar') @@ -69,14 +76,17 @@ def get_answer(invitation): # new timestamp ans.vevent.add('dtstamp') - ans.vevent.dtstamp.value = datetime.utcnow().replace( - tzinfo = invitation.vevent.dtstamp.value.tzinfo) + # ans.vevent.dtstamp.value = datetime.utcnow().replace( + # tzinfo=invitation.vevent.dtstamp.value.tzinfo) + ans.vevent.dtstamp.value = datetime.now(tz=invitation.vevent.dtstamp.value.tzinfo) return ans + def execute(command, mailtext): process = subprocess.Popen(command, stdin=subprocess.PIPE) - process.stdin.write(mailtext) - process.stdin.close() + if process.stdin is not None: + process.stdin.write(mailtext) + process.stdin.close() result = None while result is None: @@ -87,54 +97,50 @@ def execute(command, mailtext): exit code %d\nPress return to continue" % result) sys.stdin.readline() + def openics(invitation_file): - with open(invitation_file) as f: - invitation = vobject.readOne(f, ignoreUnreadable=True) - return invitation + with open(invitation_file, encoding=locale.getpreferredencoding(False)) as f: + return vobject.readOne(f, ignoreUnreadable=True) + -def format_date(value): +def format_date(value: datetime) -> str: if isinstance(value, datetime): return value.astimezone(tz=None).strftime("%Y-%m-%d %H:%M %z") - else: - return value.strftime("%Y-%m-%d %H:%M %z") + return value.strftime("%Y-%m-%d %H:%M %z") + def display(ical): summary = ical.vevent.contents['summary'][0].value if 'organizer' in ical.vevent.contents: - if hasattr(ical.vevent.organizer,'EMAIL_param'): + if hasattr(ical.vevent.organizer, 'EMAIL_param'): sender = ical.vevent.organizer.EMAIL_param else: - sender = ical.vevent.organizer.value.split(':')[1] #workaround for MS + sender = ical.vevent.organizer.value.split(':')[1] # workaround for MS else: sender = "NO SENDER" if 'description' in ical.vevent.contents: description = ical.vevent.contents['description'][0].value else: description = "NO DESCRIPTION" - if 'attendee' in ical.vevent.contents: - attendees = ical.vevent.contents['attendee'] - else: - attendees = "" - if 'location' in ical.vevent.contents: - locations = ical.vevent.contents['location'] - else: - locations = None + attendees = ical.vevent.contents.get("attendee", "") + locations = ical.vevent.contents.get("location", None) sys.stdout.write("From:\t" + sender + "\n") sys.stdout.write("Title:\t" + summary + "\n") sys.stdout.write("To:\t") for attendee in attendees: - if hasattr(attendee,'EMAIL_param') and hasattr(attendee,'CN_param'): + if hasattr(attendee, 'EMAIL_param') and hasattr(attendee, 'CN_param'): sys.stdout.write(attendee.CN_param + " <" + attendee.EMAIL_param + ">, ") else: try: - sys.stdout.write(attendee.CN_param + " <" + attendee.value.split(':')[1] + ">, ") #workaround for MS - except: - sys.stdout.write(attendee.value.split(':')[1] + " <" + attendee.value.split(':')[1] + ">, ") #workaround for 'mailto:' in email + sys.stdout.write(attendee.CN_param + " <" + attendee.value.split(':')[1] + ">, ") # workaround for MS + except Exception as e: + # workaround for 'mailto:' in email + sys.stdout.write(attendee.value.split(':')[1] + " <" + attendee.value.split(':')[1] + ">, ") sys.stdout.write("\n") if hasattr(ical.vevent, 'dtstart'): - print("Start:\t%s" % (format_date(ical.vevent.dtstart.value),)) + print(f"Start:\t{format_date(ical.vevent.dtstart.value)}") if hasattr(ical.vevent, 'dtend'): - print("End:\t%s" % (format_date(ical.vevent.dtend.value),)) + print(f"End:\t{format_date(ical.vevent.dtend.value)}") if locations: sys.stdout.write("Location:\t") for location in locations: @@ -144,23 +150,25 @@ def display(ical): sys.stdout.write("\n") sys.stdout.write(description + "\n") -def sendmail(): + +def sendmail_command(): mutt_setting = subprocess.check_output(["mutt", "-Q", "sendmail"]) - return mutt_setting.strip().decode().split("=")[1].replace('"', '').split() + return mutt_setting.strip().decode().split('sendmail=')[1].replace('"', '').split() + def organizer(ical): if 'organizer' in ical.vevent.contents: - if hasattr(ical.vevent.organizer,'EMAIL_param'): + if hasattr(ical.vevent.organizer, 'EMAIL_param'): return ical.vevent.organizer.EMAIL_param - else: - return ical.vevent.organizer.value.split(':')[1] #workaround for MS - else: - raise("no organizer in event") + return ical.vevent.organizer.value.split(':')[1] # workaround for MS + raise Exception("no organizer in event") -if __name__=="__main__": + +if __name__ == "__main__": + sendmail = sendmail_command # Set sendmail to the function that returns the command email_address = None accept_decline = 'ACCEPTED' - opts, args=getopt(sys.argv[1:],"e:aidtD") + opts, args = getopt(sys.argv[1:], "s:e:aidtD") if len(args) < 1: sys.stderr.write(usage) @@ -169,9 +177,12 @@ def organizer(ical): invitation = openics(args[0]) display(invitation) - for opt,arg in opts: + for opt, arg in opts: if opt == '-D': sys.exit(0) + if opt == '-s': + # If -s is provided, override sendmail with a lambda that returns the command + sendmail = lambda: arg.split() if opt == '-e': email_address = arg if opt == '-i': @@ -185,40 +196,76 @@ def organizer(ical): ans = get_answer(invitation) - if 'attendee' in invitation.vevent.contents: - attendees = invitation.vevent.contents['attendee'] - else: - attendees = "" - set_accept_state(attendees,accept_decline) + attendees = invitation.vevent.contents.get("attendee", "") + set_accept_state(attendees, accept_decline) ans.vevent.add('attendee') ans.vevent.attendee_list.pop() flag = 1 for attendee in attendees: - if hasattr(attendee,'EMAIL_param'): - if attendee.EMAIL_param == email_address: - ans.vevent.attendee_list.append(attendee) - flag = 0 - else: - if attendee.value.split(':')[1] == email_address: + if hasattr(attendee, 'EMAIL_param'): + if attendee.EMAIL_param.lower() == email_address.lower(): ans.vevent.attendee_list.append(attendee) flag = 0 + elif attendee.value.split(':')[1].lower() == email_address.lower(): + ans.vevent.attendee_list.append(attendee) + flag = 0 if flag: sys.stderr.write("Seems like you have not been invited to this event!\n") sys.exit(1) summary = ans.vevent.contents['summary'][0].value accept_decline = accept_decline.capitalize() - subject = "'%s: %s'" % (accept_decline, summary) + subject = f"{accept_decline}: {summary}" to = organizer(ans) + ans.vevent.add('priority') + ans.vevent.priority.value = '5' + message = EmailMessage() message['From'] = email_address message['To'] = to message['Subject'] = subject - mailtext = "'%s has %s'" % (email_address, accept_decline.lower()) + if accept_decline.lower() == "accepted": + mailtext = f"Thank you for the invitation. I, {email_address}, will be attending." + + ans.vevent.add('status') + ans.vevent.status.value = 'CONFIRMED' + ans.vevent.add('x-microsoft-cdo-busystatus') + ans.vevent.x_microsoft_cdo_busystatus.value = 'BUSY' + elif accept_decline.lower() == "tentative": + mailtext = f"Thank you for the invitation. I, {email_address}, am tentatively available and have marked this time on my calendar." + + ans.vevent.add('status') + ans.vevent.status.value = 'TENTATIVE' + ans.vevent.add('x-microsoft-cdo-busystatus') + ans.vevent.x_microsoft_cdo_busystatus.value = 'TENTATIVE' + elif accept_decline.lower() == "declined": + mailtext = f"Thank you for the invitation. Unfortunately, I, {email_address}, will not be able to attend." + else: + mailtext = "Invalid response type provided." + + message.add_alternative(mailtext, subtype='plain') message.add_alternative(ans.serialize(), subtype='calendar', - params={ 'method': 'REPLY' }) + params={'method': 'REPLY'}) + + # Assuming sendmail is either a function that returns the sendmail command or the command itself + sendmail_command = sendmail() if callable(sendmail) else sendmail + subprocess.run([*sendmail_command, "--", to], input=message.as_bytes(), check=True) - execute(sendmail() + ['--', to], message.as_bytes()) + # # From https://github.com/marvinthepa/mutt-ical/commit/c62488fbfa6a817e0f03f808c8cc14d771ce3c2d#diff-3248d42797b254937d2a6b11a3980df7c90a128ba41931a0dc8f4c1ed2c51d13R224 + # if accept_decline in {'ACCEPTED', 'TENTATIVE'}: + # # add to khal + # khal = {} + # khal['dtstart'] = None + # khal['dtend'] = None + # khal['summary'] = invitation.vevent.contents['summary'][0].value + # if hasattr(invitation.vevent, 'dtstart'): + # khal['dtstart'] = invitation.vevent.dtstart.value.astimezone(tz=None).strftime("%Y-%m-%d %H:%M") + # if hasattr(invitation.vevent, 'dtend'): + # khal['dtend'] = invitation.vevent.dtend.value.astimezone(tz=None).strftime("%Y-%m-%d %H:%M") + # if 'description' in invitation.vevent.contents: + # khal['summary'] += " :: " + # khal['summary'] += invitation.vevent.contents['description'][0].value + # execute(['khal', 'new', khal['dtstart'], khal['dtend'], khal['summary']], None)