├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── requirements.txt ├── todoist.gmail.py ├── todoist.outlook.py └── todoist.sample.conf /.gitignore: -------------------------------------------------------------------------------- 1 | # Project-specific. 2 | *.log 3 | *.log.* 4 | *.whl 5 | todoist.conf 6 | client_secret.json 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | env/ 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *,cover 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # PyBuilder 64 | target/ 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Nikita Pchelin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # todoist-email-tasks 2 | Two Python scripts that will generate a task for each starred (Gmail) or flagged (Outlook) email in [Todoist]. 3 | 4 | ## Motivation 5 | I am a big fan of Todoist and use it as my sole task management system. When I review personal or work email often there are messages that require follow up that I can not afford to do right away. I used to flag those emails and eventally add them to Todoist one by one, but every message takes time. I did not want to use forwarding feature of Todoist either, because I do not want the content of my emails to be included in the task's note. 6 | 7 | These two scripts are now setup to run every few minutes to create a task for any email that I have flagged and clear the email flag. This way I can continue using Todoist as my primary task management system and avoid doing "double entry". I hope that someone else may find it useful, too. 8 | 9 | ## Setup - Both Scripts 10 | As a general suggestion, before scheduling either of the scripts, make sure that you are able to run them through the command line. 11 | 12 | Start by renaming `todoist.sample.conf` to `todoist.conf` and specify the Todoist API key (can be found under your account settings) and Project IDs (can be found in the URL when you open any of your project folders in the browser window). 13 | 14 | After that, install requirements in `requirements.txt` via `pip`. 15 | 16 | ## Setup - Gmail 17 | In order to access Gmail from python, you need to [generate] `client_secret.json` for your Gmail account and drop it into the project folder (follow the list of steps under `Step 1: Enable the Gmail API` only). 18 | 19 | I run the Gmail script on Linux, so the cron setup looks like this for me: 20 | ```cron 21 | # Runs Todoist Task Creator. 22 | * * * * * python /opt/scripts/todoist-email-tasks/todoist.gmail.py 23 | ``` 24 | 25 | ## Setup - Outlook 26 | Besides dependencies, you may need to install [Outlook Interop Aseembly References]. 27 | 28 | You can create a Windows Scheduler task to run a batch file that will run the Outlook script: 29 | ```sh 30 | python C:\todoist-email-tasks\todoist.outlook.py 31 | ``` 32 | You can then use this cool [trick] to make the command Window invisible when the script runs. 33 | 34 | License 35 | ---- 36 | 37 | MIT 38 | 39 | [Todoist]:https://todoist.com 40 | [trick]:http://superuser.com/questions/62525/run-a-batch-file-in-a-completely-hidden-way 41 | [Outlook Interop Aseembly References]:https://www.microsoft.com/en-ca/download/details.aspx?id=3508 42 | [generate]:https://developers.google.com/gmail/api/quickstart/python 43 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jango/todoist-email-tasks/7c6e814445d45dff4695446eda8e4cce232607c1/__init__.py -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2018.4.16 2 | chardet==3.0.4 3 | google-api-python-client==1.4.1 4 | httplib2==0.11.3 5 | idna==2.6 6 | oauth2client==4.1.2 7 | pyasn1==0.4.2 8 | pyasn1-modules==0.2.1 9 | python-dateutil==2.7.2 10 | pytz==2018.4 11 | pywin32==223 12 | requests==2.18.4 13 | rsa==3.4.2 14 | six==1.11.0 15 | todoist-python==7.0.18 16 | uritemplate==3.0.0 17 | urllib3==1.22 18 | -------------------------------------------------------------------------------- /todoist.gmail.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import runpy 3 | import todoist 4 | import logging 5 | import logging.handlers 6 | from datetime import datetime 7 | 8 | import httplib2 9 | import os 10 | 11 | from apiclient import discovery 12 | import oauth2client 13 | from oauth2client import client 14 | from oauth2client import tools 15 | 16 | MY_LOCATION = os.path.dirname(os.path.realpath(__file__)) 17 | LOG_FILENAME = os.path.join(MY_LOCATION, 'todoist.gmail.log') 18 | SCOPES = ['https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/gmail.modify'] 19 | CLIENT_SECRET_FILE = 'client_secret.json' 20 | APPLICATION_NAME = 'Gmail API Quickstart' 21 | USER_ID = 'me' 22 | 23 | # Set up a specific logger with our desired output level 24 | logger = logging.getLogger(__name__) 25 | logger.setLevel(logging.INFO) 26 | 27 | # Format. 28 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 29 | 30 | # Add the log message handler to the logger 31 | handler = logging.handlers.RotatingFileHandler(LOG_FILENAME, maxBytes=1024*1024, backupCount=5) 32 | handler.setFormatter(formatter) 33 | logger.addHandler(handler) 34 | 35 | handler = logging.StreamHandler() 36 | handler.setFormatter(formatter) 37 | logger.addHandler(handler) 38 | 39 | def get_credentials(): 40 | """Straight out of Google's quickstart example: 41 | https://developers.google.com/gmail/api/quickstart/python 42 | 43 | Gets valid user credentials from storage. 44 | 45 | If nothing has been stored, or if the stored credentials are invalid, 46 | the OAuth2 flow is completed to obtain the new credentials. 47 | 48 | Returns: 49 | Credentials, the obtained credential. 50 | """ 51 | 52 | parser = argparse.ArgumentParser(parents=[tools.argparser]) 53 | flags = parser.parse_args() 54 | 55 | home_dir = os.path.expanduser('~') 56 | credential_dir = os.path.join(home_dir, '.credentials') 57 | if not os.path.exists(credential_dir): 58 | os.makedirs(credential_dir) 59 | credential_path = os.path.join(credential_dir, 60 | 'gmail-quickstart.json') 61 | 62 | store = oauth2client.file.Storage(credential_path) 63 | credentials = store.get() 64 | if not credentials or credentials.invalid: 65 | flow = client.flow_from_clientsecrets(CLIENT_SECRET_FILE, SCOPES) 66 | flow.user_agent = APPLICATION_NAME 67 | credentials = tools.run_flow(flow, store, flags) 68 | return credentials 69 | 70 | def main(): 71 | 72 | # Get config. 73 | config = runpy.run_path(os.path.join(MY_LOCATION, "todoist.conf")) 74 | 75 | # Initialize Google credentials. 76 | credentials = get_credentials() 77 | http = credentials.authorize(httplib2.Http()) 78 | 79 | # Initialize Todist API. 80 | api = todoist.TodoistAPI(config["TODOIST_API_TOKEN"]) 81 | api.sync() 82 | service = discovery.build('gmail', 'v1', http=http) 83 | 84 | # Get all gmail messages that are starred: 85 | response = service.users().messages().list(userId=USER_ID, q='label:starred').execute() 86 | 87 | messages = [] 88 | if 'messages' in response: 89 | messages.extend(response['messages']) 90 | 91 | while 'nextPageToken' in response: 92 | page_token = response['nextPageToken'] 93 | response = service.users().messages().list(userId=USER_ID, 94 | labelIds=label_ids, 95 | pageToken=page_token).execute() 96 | messages.extend(response['messages']) 97 | 98 | if len(messages) == 0: 99 | logger.info("No starred message(s).") 100 | else: 101 | logger.info("{0} starred messages found.".format(len(messages))) 102 | 103 | for m in messages: 104 | message = service.users().messages().get(userId=USER_ID, id=m["id"]).execute() 105 | 106 | subject = "No Subject" 107 | 108 | # Get email subject. 109 | for header in message['payload']['headers']: 110 | if header["name"] == "Subject": 111 | subject = header["value"] 112 | 113 | # Create a todoist item. 114 | item = api.items.add(u'https://mail.google.com/mail/u/0/#inbox/{0} (Review Email: {1})'.format(m["id"], subject), config["TODOIST_PROJECT_ID_GMAIL"], date_string="today") 115 | logger.info("Processing {0}: {1}.".format(m["id"], subject.encode("utf-8"))) 116 | r = api.commit() 117 | 118 | # Skip this item on error. 119 | if "error_code" in r: 120 | logger.info ("Task for email {0} was not created. Error: {1}:{2}".format(m["id"], r["error_code"], r["error_string"])) 121 | continue 122 | 123 | # A note that the task was auto-generated. 124 | note = api.notes.add(item["id"], u'Automatically Generated Task by Todoist Task Creator Script') 125 | r = api.commit() 126 | 127 | # Mark message as read and unstar them. 128 | thread = service.users().threads().modify(userId=USER_ID, id=m["threadId"], body={'removeLabelIds': ['UNREAD', 'STARRED'], 'addLabelIds': []}).execute() 129 | 130 | if __name__ == '__main__': 131 | main() 132 | -------------------------------------------------------------------------------- /todoist.outlook.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import runpy 4 | import base64 5 | import win32com.client 6 | import todoist 7 | import logging 8 | import logging.handlers 9 | from datetime import datetime, timedelta 10 | from dateutil.relativedelta import relativedelta 11 | import pywintypes 12 | 13 | MY_LOCATION = os.path.dirname(os.path.realpath(__file__)) 14 | LOG_FILENAME = os.path.join(MY_LOCATION, 'todoist.outlook.log') 15 | 16 | # Set up a specific logger with our desired output level 17 | logger = logging.getLogger(__name__) 18 | logger.setLevel(logging.INFO) 19 | 20 | # Format. 21 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 22 | 23 | # Add the log message handler to the logger 24 | handler = logging.handlers.RotatingFileHandler(LOG_FILENAME, maxBytes=1024*1024, backupCount=5) 25 | handler.setFormatter(formatter) 26 | logger.addHandler(handler) 27 | 28 | handler = logging.StreamHandler() 29 | handler.setFormatter(formatter) 30 | logger.addHandler(handler) 31 | 32 | def pyWinDate2datetime(now_pytime): 33 | """Converts pyWinDate to Python datetime. More info: 34 | http://timgolden.me.uk/python/win32_how_do_i/use-a-pytime-value.html""" 35 | 36 | now_datetime = datetime ( 37 | year=now_pytime.year, 38 | month=now_pytime.month, 39 | day=now_pytime.day, 40 | hour=now_pytime.hour, 41 | minute=now_pytime.minute, 42 | second=now_pytime.second 43 | ) 44 | 45 | return now_datetime 46 | 47 | def main(): 48 | 49 | # Get config. 50 | config = runpy.run_path(os.path.join(MY_LOCATION, "todoist.conf")) 51 | 52 | # Init Todoist API. 53 | api = todoist.TodoistAPI(config["TODOIST_API_TOKEN"]) 54 | api.sync() 55 | 56 | # For some background on Outlook Interop model, see: 57 | # https://msdn.microsoft.com/en-us/library/office/ff861868%28v=office.15%29.aspx 58 | # https://msdn.microsoft.com/en-us/library/microsoft.office.interop.outlook.mailitem_properties(v=office.14).aspx 59 | olFolderTodo = 28 60 | outlook = win32com.client.Dispatch("Outlook.Application") 61 | ns = outlook.GetNamespace("MAPI") 62 | todo_folder = ns.GetDefaultFolder(olFolderTodo) 63 | todo_items = todo_folder.Items 64 | tasks = todo_items.Restrict("[Complete] = FALSE") 65 | 66 | if (len(tasks) == 0): 67 | logger.info("No flagged emails found.") 68 | else: 69 | logger.info("{0} flagged emails will be processed.".format(len(tasks))) 70 | 71 | for task in tasks: 72 | try: 73 | creation_date = pyWinDate2datetime(task.CreationTime) 74 | 75 | # Do not create reminders for emails that came in today. 76 | if not creation_date.date() < datetime.today().date(): 77 | print("Skipped, since it was created today.") 78 | continue 79 | 80 | except AttributeError as ex: 81 | logger.exception("Exception occured while processing the message.", ex) 82 | message = todo_items.GetNext() 83 | continue 84 | 85 | # How Todoist generates an ID for an Outlook email: 86 | # https://todoist.com/Support/show/30790/ 87 | 88 | # How to get PR_INTERNET_MESSAGE_ID for an Outlook item: 89 | # http://www.slipstick.com/developer/read-mapi-properties-exposed-outlooks-object-model/ 90 | PR_INTERNET_MESSAGE_ID = task.PropertyAccessor.GetProperty("http://schemas.microsoft.com/mapi/proptag/0x1035001E") 91 | todoist_message_id = base64.b64encode(bytes("id={0};mid={1}".format(task.EntryID, PR_INTERNET_MESSAGE_ID), "utf-8")).decode("utf-8") 92 | 93 | # Add a task. 94 | item = api.items.add(u'[[outlook=id3={0}, Review Email: {1}]]'.format(todoist_message_id, task.Subject), config["TODOIST_PROJECT_ID_OUTLOOK"], date_string="") 95 | logger.info("Processing '{0}'.".format(task.Subject.encode("utf-8"))) 96 | r = api.commit() 97 | 98 | # If error occures, move on. 99 | if "error_code" in r: 100 | logger.info ("Task for email '{0}' was not created. Error: {1}:{2}".format(task.Subject, r["error_code"], r["error_string"])) 101 | continue 102 | 103 | # Add a note. 104 | note = api.notes.add(item["id"], u'Automatically Generated Task by Todoist Task Creator Script') 105 | r = api.commit() 106 | 107 | # Mark message as read. 108 | task.TaskCompletedDate = task.CreationTime 109 | task.Save() 110 | 111 | if __name__=="__main__": 112 | main() 113 | -------------------------------------------------------------------------------- /todoist.sample.conf: -------------------------------------------------------------------------------- 1 | TODOIST_API_TOKEN = '' # Todoist Token (str) 2 | TODOIST_PROJECT_ID_GMAIL = 0 # Todoist Project ID (int) 3 | TODOIST_PROJECT_ID_OUTLOOK = 0 # Outlook Project ID (int) --------------------------------------------------------------------------------