From 1e17c0b3d44e3cb81f61ebafea433d861cf38653 Mon Sep 17 00:00:00 2001 From: dagz214 <58767064+dagz214@users.noreply.github.com> Date: Thu, 22 Apr 2021 00:35:10 +0300 Subject: [PATCH 01/22] Added function to create new labels For now, labels are created just with "name", no background of foreground colors. On branch create-list Changes to be committed: modified: simplegmail/gmail.py --- simplegmail/gmail.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/simplegmail/gmail.py b/simplegmail/gmail.py index 7e4ad17..657850a 100644 --- a/simplegmail/gmail.py +++ b/simplegmail/gmail.py @@ -560,6 +560,41 @@ def list_labels(self, user_id: str = 'me') -> List[Label]: labels = [Label(name=x['name'], id=x['id']) for x in res['labels']] return labels + def create_label(self, label_name: str, user_id: str = 'me') -> Label: + """ + Create a new label + + Args: + label_name: Name for the new label + + user_id: The user's email address. By default, the authenticated + user. + + Returns: + A Label object. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + + """ + body = { + "name": label_name, + } + + try: + res = self.service.users().labels().create( + userId=user_id, + body=body + ).execute() + + except HttpError as error: + # Pass along the error + raise error + + else: + return Label(res['name'], res['id']) + def _get_messages_from_refs( self, user_id: str, From 407c0e3aeb33a61df76108fa71f69f7cf6f76780 Mon Sep 17 00:00:00 2001 From: dagz214 <58767064+dagz214@users.noreply.github.com> Date: Thu, 22 Apr 2021 01:06:15 +0300 Subject: [PATCH 02/22] new file: requirements.txt --- requirements.txt | Bin 0 -> 1022 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..6b29b5d151891ade209ea427c41a4bd7efee7fc0 GIT binary patch literal 1022 zcmaJ=(N4lZ5S(WdKc&PTAoAeH2pqJr&_at5KVF^Ly#tJBn!7f4yR$R1+po_BB3^LB zfEsJQ8}w-T?l8eKp9w~obB%G2J1ltX=MOy7D(!qpj3p{!4a{y0C1;1LsE9+VR2zCA zhL&&?H%(|2@eWuSUdHi?VzIFr8&-)Z7}=3~!TK$8qT!ar3V7u1yl-c8IFU`oyq)DY z#EJYK5;tK;<*k}>th%ZtkN%4+7(MXm$hxMF!E#;stxQ+dCtX`?6U&YoTjI3rgq)Gm zDOF~SzpIF$cdMqeJNl-4?~XHXdFHwT-Tdi>=0LUF&yJBxymH@A&5pZcNzdH;y6{{O zb8YuxS(5DtPbua^gr51O-78yle%}_Re`WI-lX69i{Uutg4!pf)_`2x0)p}e7AEs>W+8^^1r8u nh&Qg>Bkz;4kJMNj^Q}#jt5BRzL1wSGCQaM;T+RQAiTUvjjC_{> literal 0 HcmV?d00001 From cdfe068e85678a7d545b91cc21700f1615bc5783 Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Date: Sat, 24 Apr 2021 07:54:00 -0700 Subject: [PATCH 03/22] used include_spam_trash --- setup.py | 2 +- simplegmail/gmail.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 9ecc11a..054e5d4 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setuptools.setup( name="simplegmail", - version="3.1.5", + version="3.1.6", url="https://github.com/jeremyephron/simplegmail", author="Jeremy Ephron", author_email="jeremyephron@gmail.com", diff --git a/simplegmail/gmail.py b/simplegmail/gmail.py index 657850a..53103c8 100644 --- a/simplegmail/gmail.py +++ b/simplegmail/gmail.py @@ -502,7 +502,8 @@ def get_messages( response = self.service.users().messages().list( userId=user_id, q=query, - labelIds=labels_ids + labelIds=labels_ids, + includeSpamTrash=include_spam_trash ).execute() message_refs = [] @@ -515,6 +516,7 @@ def get_messages( userId=user_id, q=query, labelIds=labels_ids, + includeSpamTrash=include_spam_trash, pageToken=page_token ).execute() From a83da57891c42dc6658a16b36f0b44e78f17d52a Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Date: Sat, 24 Apr 2021 08:58:59 -0700 Subject: [PATCH 04/22] changed exclude_KEYWORD behavior in construct query --- setup.py | 2 +- simplegmail/query.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 054e5d4..670425d 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setuptools.setup( name="simplegmail", - version="3.1.6", + version="4.0.0", url="https://github.com/jeremyephron/simplegmail", author="Jeremy Ephron", author_email="jeremyephron@gmail.com", diff --git a/simplegmail/query.py b/simplegmail/query.py index f183c18..58dc186 100644 --- a/simplegmail/query.py +++ b/simplegmail/query.py @@ -131,8 +131,10 @@ def construct_query(*query_dicts, **query_terms): terms = [] for key, val in query_terms.items(): + exclude = False if key.startswith('exclude'): - continue + exclude = True + key = key[len('exclude_'):] query_fn = globals()[f"_{key}"] conjunction = _and if isinstance(val, tuple) else _or @@ -155,7 +157,7 @@ def construct_query(*query_dicts, **query_terms): else: term = query_fn(val) if not isinstance(val, bool) else query_fn() - if f'exclude_{key}' in query_terms: + if exclude: term = _exclude(term) terms.append(term) From d9df8227e4e6c17730192424e84eba11263690ba Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Barenholtz Date: Sat, 24 Apr 2021 08:44:14 -0700 Subject: [PATCH 05/22] Create python-publish.yml --- .github/workflows/python-publish.yml | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/python-publish.yml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..4d17344 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,31 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.6' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* From 0d00fb757be4b722d9c81cfc87f8bb958b492958 Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Barenholtz Date: Sat, 24 Apr 2021 08:51:02 -0700 Subject: [PATCH 06/22] Update python-publish.yml --- .github/workflows/python-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 4d17344..44711b5 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -24,8 +24,8 @@ jobs: pip install setuptools wheel twine - name: Build and publish env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + TWINE_USERNAME: '__token__' + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: | python setup.py sdist bdist_wheel twine upload dist/* From 982fec4aaf4ec65bc07ca158ff45620b4a0ae92b Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Date: Sat, 24 Apr 2021 10:06:59 -0700 Subject: [PATCH 07/22] updated formatting, comments, tests for v4.0.0 --- simplegmail/query.py | 319 ++++++++++++++++++++++--------------------- tests/test_query.py | 51 +++++-- 2 files changed, 199 insertions(+), 171 deletions(-) diff --git a/simplegmail/query.py b/simplegmail/query.py index 58dc186..338b81b 100644 --- a/simplegmail/query.py +++ b/simplegmail/query.py @@ -5,13 +5,16 @@ """ -def construct_query(*query_dicts, **query_terms): +from typing import List, Tuple, Union + + +def construct_query(*query_dicts, **query_terms) -> str: """ Constructs a query from either: - (1) a list of dictionaries representing queries to "or" - (only one of the queries needs to match). Each of these dictionaries - should be made up of keywords as specified below. + (1) a list of dictionaries representing queries to "or" (only one of the + queries needs to match). Each of these dictionaries should be made up + of keywords as specified below. E.g.: construct_query( @@ -29,9 +32,13 @@ def construct_query(*query_dicts, **query_terms): be and'd). - To negate any term, add a term "exclude_" and set it's value - to True. (for example, `starred=True, exclude_starred=True` will - exclude all starred messages). + To negate any term, set it as the value of "exclude_" instead of + "" (for example, since `labels=['finance', 'bills']` will match + messages with both the 'finance' and 'bills' labels, + `exclude_labels=['finance', 'bills']` will exclude messages that have both + labels. To exclude either you must specify + `exclude_labels=[['finance'], ['bills']]`, which negates + '(finance OR bills)'. For all keywords whose values are not booleans, you can indicate you'd like to "and" multiple values by placing them in a tuple (), or "or" @@ -39,87 +46,77 @@ def construct_query(*query_dicts, **query_terms): Keyword Arguments: sender (str): Who the message is from. - E.g.: sender='someone@email.com' - sender=['john@doe.com', 'jane@doe.com'] # or + E.g.: sender='someone@email.com' + sender=['john@doe.com', 'jane@doe.com'] # OR - recipient (str): Who the message is to. - E.g.: recipient='someone@email.com' + recipient (str): Who the message is to. + E.g.: recipient='someone@email.com' - subject (str): The subject of the message. - E.g.: subject='Meeting' + subject (str): The subject of the message. E.g.: subject='Meeting' labels (List[str]): Labels applied to the message (all must match). - E.g.: labels=['Work', 'HR'] - labels=[['Work', 'HR'], ['Home']] # or + E.g.: labels=['Work', 'HR'] # Work AND HR + labels=[['Work', 'HR'], ['Home']] # (Work AND HR) OR Home - attachment (bool): The message has an attachment. - E.g.: attachment=True + attachment (bool): The message has an attachment. E.g.: attachment=True spec_attachment (str): The message has an attachment with a - specific name or file type. - E.g.: spec_attachment='pdf' - spec_attachment='homework.docx' + specific name or file type. + E.g.: spec_attachment='pdf', + spec_attachment='homework.docx' exact_phrase (str): The message contains an exact phrase. - E.g.: exact_phrase='I need help' - exact_phrase=('help me', 'homework') # and + E.g.: exact_phrase='I need help' + exact_phrase=('help me', 'homework') # AND - cc (str): Recipient in the cc field. - E.g.: cc='john@email.com' + cc (str): Recipient in the cc field. E.g.: cc='john@email.com' - bcc (str): Recipient in the bcc field. - E.g.: bcc='jane@email.com' + bcc (str): Recipient in the bcc field. E.g.: bcc='jane@email.com' - before (str): The message was sent before a date. - E.g.: before='2004/04/27' + before (str): The message was sent before a date. + E.g.: before='2004/04/27' - after (str): The message was sent after a date. - E.g.: after='2004/04/27' + after (str): The message was sent after a date. + E.g.: after='2004/04/27' older_than (Tuple[int, str]): The message was sent before a given - time period. - E.g.: older_than=(3, "day") - older_than=(1, "month") - older_than=(2, "year") + time period. + E.g.: older_than=(3, "day") + older_than=(1, "month") + older_than=(2, "year") newer_than (Tuple[int, str]): The message was sent after a given - time period. - E.g.: newer_than=(3, "day") - newer_than=(1, "month") - newer_than=(2, "year") + time period. + E.g.: newer_than=(3, "day") + newer_than=(1, "month") + newer_than=(2, "year") - near_words (Tuple[str, str, int]): The message contains two words - near each other. (The third item - is the max number of words - between the two words). - E.g.: near_words=('CS', 'hw', 5) + near_words (Tuple[str, str, int]): The message contains two words near + each other. (The third item is the max number of words between the + two words). E.g.: near_words=('CS', 'hw', 5) - starred (bool): The message was starred. - E.g.: starred=True + starred (bool): The message was starred. E.g.: starred=True - snoozed (bool): The message was snoozed. - E.g.: snoozed=True + snoozed (bool): The message was snoozed. E.g.: snoozed=True - unread (bool): The message is unread. - E.g.: unread=True + unread (bool): The message is unread. E.g.: unread=True - read (bool): The message has been read. - E.g.: read=True + read (bool): The message has been read. E.g.: read=True - important (bool): The message was marked as important. - E.g.: important=True + important (bool): The message was marked as important. + E.g.: important=True drive (bool): The message contains a Google Drive attachment. - E.g.: drive=True + E.g.: drive=True docs (bool): The message contains a Google Docs attachment. - E.g.: docs=True + E.g.: docs=True sheets (bool): The message contains a Google Sheets attachment. - E.g.: sheets=True + E.g.: sheets=True slides (bool): The message contains a Google Slides attachment. - E.g.: slides=True + E.g.: slides=True Returns: The query string. @@ -165,12 +162,12 @@ def construct_query(*query_dicts, **query_terms): return _and(terms) -def _and(queries): +def _and(queries: List[str]) -> str: """ - Returns a query item matching the "and" of all query items. + Returns a query term matching the "and" of all query terms. Args: - queries (List[str]): A list of query terms to and. + queries: A list of query terms to and. Returns: The query string. @@ -180,15 +177,15 @@ def _and(queries): if len(queries) == 1: return queries[0] - return f"({' '.join(queries)})" + return f'({" ".join(queries)})' -def _or(queries): +def _or(queries: List[str]) -> str: """ - Returns a query item matching the "or" of all query items. + Returns a query term matching the "or" of all query terms. Args: - queries (List[str]): A list of query terms to or. + queries: A list of query terms to or. Returns: The query string. @@ -198,57 +195,57 @@ def _or(queries): if len(queries) == 1: return queries[0] - return "{" + ' '.join(queries) + "}" + return '{' + ' '.join(queries) + '}' -def _exclude(term): +def _exclude(term: str) -> str: """ - Returns a query item excluding messages that match the given query term. + Returns a query term excluding messages that match the given query term. Args: - term (str): The query term to be excluded. + term: The query term to be excluded. Returns: The query string. """ - return f"-{term}" + return f'-{term}' -def _sender(sender): +def _sender(sender: str) -> str: """ - Returns a query item matching "from". + Returns a query term matching "from". Args: - sender (str): The sender of the message. + sender: The sender of the message. Returns: The query string. """ - return f"from:{sender}" + return f'from:{sender}' -def _recipient(recipient): +def _recipient(recipient: str) -> str: """ - Returns a query item matching "to". + Returns a query term matching "to". Args: - recipient (str): The recipient of the message. + recipient: The recipient of the message. Returns: The query string. """ - return f"to:{recipient}" + return f'to:{recipient}' -def _subject(subject): +def _subject(subject: str) -> str: """ - Returns a query item matching "subject". + Returns a query term matching "subject". Args: subject: The subject of the message. @@ -258,16 +255,16 @@ def _subject(subject): """ - return f"subject:{subject}" + return f'subject:{subject}' -def _labels(labels): +def _labels(labels: Union[List[str], str]) -> str: """ - Returns a query item matching a multiple labels. + Returns a query term matching a multiple labels. Works with a single label (str) passed in, instead of the expected list. Args: - labels (List[str]): A list of labels the message must have applied. + labels: A list of labels the message must have applied. Returns: The query string. @@ -279,45 +276,44 @@ def _labels(labels): return _and([_label(label) for label in labels]) -def _label(label): + +def _label(label: str) -> str: """ - Returns a query item matching a label. + Returns a query term matching a label. Args: - label (str): The label the message must have applied. + label: The label the message must have applied. Returns: The query string. """ - return f"label:{label}" + return f'label:{label}' -def _spec_attachment(name_or_type): +def _spec_attachment(name_or_type: str) -> str: """ - Returns a query item matching messages that have attachments with a + Returns a query term matching messages that have attachments with a certain name or file type. Args: - name_or_type (str): The specific name of file type to match. - + name_or_type: The specific name of file type to match. Returns: The query string. """ - return f"filename:{name_or_type}" + return f'filename:{name_or_type}' -def _exact_phrase(phrase): +def _exact_phrase(phrase: str) -> str: """ - Returns a query item matching messages that have an exact phrase. + Returns a query term matching messages that have an exact phrase. Args: - phrase (str): The exact phrase to match. - + phrase: The exact phrase to match. Returns: The query string. @@ -327,190 +323,195 @@ def _exact_phrase(phrase): return f'"{phrase}"' -def _starred(): - """Returns a query item matching messages that are starred.""" +def _starred() -> str: + """Returns a query term matching messages that are starred.""" - return f"is:starred" + return 'is:starred' -def _snoozed(): - """Returns a query item matching messages that are snoozed.""" +def _snoozed() -> str: + """Returns a query term matching messages that are snoozed.""" - return f"is:snoozed" + return 'is:snoozed' -def _unread(): - """Returns a query item matching messages that are unread.""" +def _unread() -> str: + """Returns a query term matching messages that are unread.""" - return f"is:unread" + return 'is:unread' -def _read(): - """Returns a query item matching messages that are read.""" +def _read() -> str: + """Returns a query term matching messages that are read.""" - return f"is:read" + return 'is:read' -def _important(): - """Returns a query item matching messages that are important.""" +def _important() -> str: + """Returns a query term matching messages that are important.""" - return f"is:important" + return 'is:important' -def _cc(recipient): +def _cc(recipient: str) -> str: """ - Returns a query item matching messages that have certain recipients in + Returns a query term matching messages that have certain recipients in the cc field. Args: - recipient (str): The recipient in the cc field to match. + recipient: The recipient in the cc field to match. Returns: The query string. """ - return f"cc:{recipient}" + return f'cc:{recipient}' -def _bcc(recipient): +def _bcc(recipient: str) -> str: """ - Returns a query item matching messages that have certain recipients in + Returns a query term matching messages that have certain recipients in the bcc field. Args: - recipient (str): The recipient in the bcc field to match. + recipient: The recipient in the bcc field to match. Returns: The query string. """ - return f"bcc:{recipient}" + return f'bcc:{recipient}' -def _after(date): +def _after(date: str) -> str: """ - Returns a query item matching messages sent after a given date. + Returns a query term matching messages sent after a given date. Args: - date (str): The date messages must be sent after. + date: The date messages must be sent after. Returns: The query string. """ - return f"after:{date}" + return f'after:{date}' -def _before(date): +def _before(date: str) -> str: """ - Returns a query item matching messages sent before a given date. + Returns a query term matching messages sent before a given date. Args: - date (str): The date messages must be sent before. + date: The date messages must be sent before. Returns: The query string. """ - return f"before:{date}" + return f'before:{date}' -def _older_than(number, unit): +def _older_than(number: int, unit: str) -> str: """ - Returns a query item matching messages older than a time period. + Returns a query term matching messages older than a time period. Args: - number (int): The number of units of time of the period. - unit (str): The unit of time: "day", "month", or "year". + number: The number of units of time of the period. + unit: The unit of time: "day", "month", or "year". Returns: The query string. """ - return f"older_than:{number}{unit[0]}" + return f'older_than:{number}{unit[0]}' -def _newer_than(number, unit): +def _newer_than(number: int, unit: str) -> str: """ - Returns a query item matching messages newer than a time period. + Returns a query term matching messages newer than a time period. Args: - number (int): The number of units of time of the period. - unit (str): The unit of time: "day", "month", or "year". + number: The number of units of time of the period. + unit: The unit of time: 'day', 'month', or 'year'. Returns: The query string. """ - return f"newer_than:{number}{unit[0]}" + return f'newer_than:{number}{unit[0]}' -def _near_words(first, second, distance, exact=False): +def _near_words( + first: str, + second: str, + distance: int, + exact: bool = False +) -> str: """ - Returns a query item matching messages that two words within a certain + Returns a query term matching messages that two words within a certain distance of each other. Args: - first (str): The first word to search for. - second (str): The second word to search for. - distance (int): How many words apart first and second can be. - exact (bool): Whether first must come before second [default False]. + first: The first word to search for. + second: The second word to search for. + distance: How many words apart first and second can be. + exact: Whether first must come before second [default False]. Returns: The query string. """ - query = f"{first} AROUND {distance} {second}" + query = f'{first} AROUND {distance} {second}' if exact: query = '"' + query + '"' return query -def _attachment(): - """Returns a query item matching messages that have attachments.""" +def _attachment() -> str: + """Returns a query term matching messages that have attachments.""" - return f"has:attachment" + return 'has:attachment' -def _drive(): +def _drive() -> str: """ - Returns a query item matching messages that have Google Drive attachments. + Returns a query term matching messages that have Google Drive attachments. """ - return f"has:drive" + return 'has:drive' -def _docs(): +def _docs() -> str: """ - Returns a query item matching messages that have Google Docs attachments. + Returns a query term matching messages that have Google Docs attachments. """ - return f"has:document" + return 'has:document' -def _sheets(): +def _sheets() -> str: """ - Returns a query item matching messages that have Google Sheets attachments. + Returns a query term matching messages that have Google Sheets attachments. """ - return f"has:spreadsheet" + return 'has:spreadsheet' -def _slides(): +def _slides() -> str: """ - Returns a query item matching messages that have Google Slides attachments. + Returns a query term matching messages that have Google Slides attachments. """ - return f"has:presentation" + return 'has:presentation' diff --git a/tests/test_query.py b/tests/test_query.py index c9f83f2..6ffb84e 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -6,14 +6,32 @@ def test_and(self): _and = query._and expect = "(((a b c) (d e f)) ((g h i) j))" - string = _and([_and([_and(['a', 'b', 'c']), _and(['d', 'e', 'f'])]), _and([_and(['g', 'h', 'i']), 'j'])]) + string = _and([ + _and([ + _and(['a', 'b', 'c']), + _and(['d', 'e', 'f']) + ]), + _and([ + _and(['g', 'h', 'i']), + 'j' + ]) + ]) assert string == expect def test_or(self): _or = query._or expect = "{{{a b c} {d e f}} {{g h i} j}}" - string = _or([_or([_or(['a', 'b', 'c']), _or(['d', 'e', 'f'])]), _or([_or(['g', 'h', 'i']), 'j'])]) + string = _or([ + _or([ + _or(['a', 'b', 'c']), + _or(['d', 'e', 'f']) + ]), + _or([ + _or(['g', 'h', 'i']), + 'j' + ]) + ]) assert string == expect def test_exclude(self): @@ -25,24 +43,33 @@ def test_exclude(self): def test_construct_query_from_keywords(self): expect = "({from:john@doe.com from:jane@doe.com} subject:meeting)" - - query_string = query.construct_query(sender=['john@doe.com', 'jane@doe.com'], - subject='meeting') - + query_string = query.construct_query( + sender=['john@doe.com', 'jane@doe.com'], subject='meeting' + ) assert query_string == expect expect = "(-is:starred (label:work label:HR))" - query_string = query.construct_query(starred=True, exclude_starred=True, labels=['work', 'HR']) + query_string = query.construct_query(exclude_starred=True, + labels=['work', 'HR']) assert query_string == expect expect = "{(label:work label:HR) (label:wife label:house)}" - query_string = query.construct_query(labels=[['work', 'HR'], ['wife', 'house']]) + query_string = query.construct_query( + labels=[['work', 'HR'], ['wife', 'house']] + ) assert query_string == expect def test_construct_query_from_dicts(self): expect = "{(from:john@doe.com newer_than:1d {subject:meeting subject:HR}) (to:jane@doe.com CS AROUND 5 homework)}" - - query_string = query.construct_query({'sender': 'john@doe.com', 'newer_than': (1, 'day'), 'subject': ['meeting', 'HR']}, - {'recipient': 'jane@doe.com', 'near_words': ('CS', 'homework', 5)}) - + query_string = query.construct_query( + dict( + sender='john@doe.com', + newer_than=(1, 'day'), + subject=['meeting', 'HR'] + ), + dict( + recipient='jane@doe.com', + near_words=('CS', 'homework', 5) + ) + ) assert query_string == expect From e9e6c81a5d6b5a32151331a5a9ae91a2e71473a7 Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Date: Sun, 25 Apr 2021 14:33:07 -0700 Subject: [PATCH 08/22] Message: expose headers --- setup.py | 2 +- simplegmail/gmail.py | 7 +++++-- simplegmail/message.py | 8 +++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index 670425d..5b46fe1 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setuptools.setup( name="simplegmail", - version="4.0.0", + version="4.0.1", url="https://github.com/jeremyephron/simplegmail", author="Jeremy Ephron", author_email="jeremyephron@gmail.com", diff --git a/simplegmail/gmail.py b/simplegmail/gmail.py index 53103c8..6606879 100644 --- a/simplegmail/gmail.py +++ b/simplegmail/gmail.py @@ -674,7 +674,7 @@ def _build_message_from_ref( self, user_id: str, message_ref: dict, - attachments: Union['ignore', 'reference', 'download'] = 'reference', + attachments: Union['ignore', 'reference', 'download'] = 'reference' ) -> Message: """ Creates a Message object from a reference. @@ -725,6 +725,7 @@ def _build_message_from_ref( sender = '' recipient = '' subject = '' + msg_hdrs = {} for hdr in headers: if hdr['name'] == 'Date': try: @@ -737,6 +738,8 @@ def _build_message_from_ref( recipient = hdr['value'] elif hdr['name'] == 'Subject': subject = hdr['value'] + + msg_hdrs[hdr['name']] = hdr['value'] parts = self._evaluate_message_payload( payload, user_id, message_ref['id'], attachments @@ -764,7 +767,7 @@ def _build_message_from_ref( return Message(self.service, user_id, msg_id, thread_id, recipient, sender, subject, date, snippet, plain_msg, html_msg, label_ids, - attms) + attms, msg_hdrs) def _evaluate_message_payload( self, diff --git a/simplegmail/message.py b/simplegmail/message.py index c90fa03..5be45dd 100644 --- a/simplegmail/message.py +++ b/simplegmail/message.py @@ -16,8 +16,8 @@ class Message(object): """ - The Message class for emails in your Gmail mailbox. This class should not be - manually constructed. Contains all information about the associated + The Message class for emails in your Gmail mailbox. This class should not + be manually constructed. Contains all information about the associated message, and can be used to modify the message's labels (e.g., marking as read/unread, archiving, moving to trash, starring, etc.). @@ -66,7 +66,8 @@ def __init__( plain: Optional[str] = None, html: Optional[str] = None, label_ids: Optional[List[str]] = None, - attachments: Optional[List[Attachment]] = None + attachments: Optional[List[Attachment]] = None, + headers: Optional[dict] = None ) -> None: self._service = service self.user_id = user_id @@ -81,6 +82,7 @@ def __init__( self.html = html self.label_ids = label_ids if label_ids is not None else [] self.attachments = attachments if attachments is not None else [] + self.headers = headers if headers else {} def __repr__(self) -> str: """Represents the object by its sender, recipient, and id.""" From 4f40588e9591e9917ee09f214ae7fc37903b99d2 Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Barenholtz Date: Sat, 24 Apr 2021 11:59:40 -0700 Subject: [PATCH 09/22] Create FUNDING.yml --- .github/FUNDING.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..c03b29a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: jeremyephron From 99d924d6759b305ba2cd3289e3e457571387b754 Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Barenholtz Date: Mon, 26 Apr 2021 10:33:50 -0700 Subject: [PATCH 10/22] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index cdf3a05..5349d35 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,6 @@ query_params_2 = { "newer_than": (1, "month"), "unread": True, "labels": ["Top Secret"], - "starred": True, "exclude_starred": True } From 110cad08d34f7fa505e7157aeaf975d3b76c6a64 Mon Sep 17 00:00:00 2001 From: Daniele Pannone Date: Wed, 5 May 2021 16:37:45 -0700 Subject: [PATCH 11/22] fix alias info request for nonstandard user_id --- simplegmail/gmail.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/simplegmail/gmail.py b/simplegmail/gmail.py index 6606879..8f85fdb 100644 --- a/simplegmail/gmail.py +++ b/simplegmail/gmail.py @@ -142,7 +142,7 @@ def send_message( msg = self._create_message( sender, to, subject, msg_html, msg_plain, cc=cc, bcc=bcc, - attachments=attachments, signature=signature + attachments=attachments, signature=signature, user_id=user_id ) try: @@ -863,7 +863,8 @@ def _create_message( cc: List[str] = None, bcc: List[str] = None, attachments: List[str] = None, - signature: bool = False + signature: bool = False, + user_id: str = 'me' ) -> dict: """ Creates the raw email message to be sent. @@ -899,7 +900,10 @@ def _create_message( msg['Bcc'] = ', '.join(bcc) if signature: - account_sig = self._get_alias_info(sender, 'me')['signature'] + if user_id != 'me': + account_sig = self._get_alias_info(user_id, 'me')['signature'] + else: + account_sig = self._get_alias_info(sender, user_id)['signature'] if msg_html is None: msg_html = '' From 8b82efc1bddae3a0de47eb599e9b798f58a59025 Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Date: Wed, 5 May 2021 17:05:54 -0700 Subject: [PATCH 12/22] signature authentication fix --- simplegmail/gmail.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/simplegmail/gmail.py b/simplegmail/gmail.py index 8f85fdb..844a900 100644 --- a/simplegmail/gmail.py +++ b/simplegmail/gmail.py @@ -18,6 +18,7 @@ import math # for math.ceil import mimetypes # for mimetypes.guesstype import os # for os.path.basename +import re # for re.match import threading # for threading.Thread from typing import List, Optional, Union @@ -900,10 +901,10 @@ def _create_message( msg['Bcc'] = ', '.join(bcc) if signature: - if user_id != 'me': - account_sig = self._get_alias_info(user_id, 'me')['signature'] - else: - account_sig = self._get_alias_info(sender, user_id)['signature'] + m = re.match(r'.+\s<(?P.+@.+\..+)>', sender) + address = m.group('addr') if m else sender + account_sig = self._get_alias_info(address, user_id)['signature'] + if msg_html is None: msg_html = '' From 35d6c791cef787c964f0179495886e913d33b4d9 Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Date: Wed, 5 May 2021 17:09:24 -0700 Subject: [PATCH 13/22] signature bug fix --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5b46fe1..0d1296a 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setuptools.setup( name="simplegmail", - version="4.0.1", + version="4.0.2", url="https://github.com/jeremyephron/simplegmail", author="Jeremy Ephron", author_email="jeremyephron@gmail.com", From 0ffc64866dba526e59da0b4b4c7e085bb14f39e0 Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Date: Sat, 5 Jun 2021 14:27:59 -0700 Subject: [PATCH 14/22] case insensitive header check --- setup.py | 2 +- simplegmail/gmail.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 0d1296a..b421616 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setuptools.setup( name="simplegmail", - version="4.0.2", + version="4.0.3", url="https://github.com/jeremyephron/simplegmail", author="Jeremy Ephron", author_email="jeremyephron@gmail.com", diff --git a/simplegmail/gmail.py b/simplegmail/gmail.py index 844a900..3053500 100644 --- a/simplegmail/gmail.py +++ b/simplegmail/gmail.py @@ -728,16 +728,16 @@ def _build_message_from_ref( subject = '' msg_hdrs = {} for hdr in headers: - if hdr['name'] == 'Date': + if hdr['name'].lower() == 'date': try: date = str(parser.parse(hdr['value']).astimezone()) except Exception: date = hdr['value'] - elif hdr['name'] == 'From': + elif hdr['name'].lower() == 'from': sender = hdr['value'] - elif hdr['name'] == 'To': + elif hdr['name'].lower() == 'to': recipient = hdr['value'] - elif hdr['name'] == 'Subject': + elif hdr['name'].lower() == 'subject': subject = hdr['value'] msg_hdrs[hdr['name']] = hdr['value'] From 079d5b453a1325d6a5f131e5832a58013d51c661 Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Barenholtz Date: Sat, 23 Oct 2021 23:47:41 -0700 Subject: [PATCH 15/22] refresh tokens (#50) * refresh token if expired on access to service * add refresh in Message * update version --- setup.py | 4 +- simplegmail/gmail.py | 206 +++++++++++++++++++++-------------------- simplegmail/message.py | 68 ++++++++------ 3 files changed, 149 insertions(+), 129 deletions(-) diff --git a/setup.py b/setup.py index b421616..a191fcf 100644 --- a/setup.py +++ b/setup.py @@ -2,10 +2,10 @@ setuptools.setup( name="simplegmail", - version="4.0.3", + version="4.0.4", url="https://github.com/jeremyephron/simplegmail", author="Jeremy Ephron", - author_email="jeremyephron@gmail.com", + author_email="jeremye@cs.stanford.edu", description="A simple Python API client for Gmail.", long_description=open('README.md').read(), long_description_content_type='text/markdown', diff --git a/simplegmail/gmail.py b/simplegmail/gmail.py index 3053500..221a138 100644 --- a/simplegmail/gmail.py +++ b/simplegmail/gmail.py @@ -6,24 +6,23 @@ """ -import base64 # for base64.urlsafe_b64decode -# MIME parts for constructing a message +import base64 from email.mime.audio import MIMEAudio from email.mime.application import MIMEApplication from email.mime.base import MIMEBase from email.mime.image import MIMEImage from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -import html # for html.unescape -import math # for math.ceil -import mimetypes # for mimetypes.guesstype -import os # for os.path.basename -import re # for re.match -import threading # for threading.Thread -from typing import List, Optional, Union - -from bs4 import BeautifulSoup # for parsing email HTML -import dateutil.parser as parser # for parsing email date +import html +import math +import mimetypes +import os +import re +import threading +from typing import List, Optional + +from bs4 import BeautifulSoup +import dateutil.parser as parser from googleapiclient.discovery import build from googleapiclient.errors import HttpError from httplib2 import Http @@ -31,9 +30,9 @@ from oauth2client.clientsecrets import InvalidClientSecretsError from simplegmail import label +from simplegmail.attachment import Attachment from simplegmail.label import Label from simplegmail.message import Message -from simplegmail.attachment import Attachment class Gmail(object): @@ -63,11 +62,11 @@ def __init__( self, client_secret_file: str = 'client_secret.json', creds_file: str = 'gmail_token.json', - _creds: Optional[client.Credentials] = None + _creds: Optional[client.OAuth2Credentials] = None ) -> None: self.client_secret_file = client_secret_file self.creds_file = creds_file - + try: # The file gmail_token.json stores the user's access and refresh # tokens, and is created automatically when the authorization flow @@ -86,7 +85,7 @@ def __init__( ) self.creds = tools.run_flow(flow, store) - self.service = build( + self._service = build( 'gmail', 'v1', http=self.creds.authorize(Http()), cache_discovery=False ) @@ -100,12 +99,21 @@ def __init__( "follow the instructions listed there." ) + @property + def service(self) -> 'googleapiclient.discovery.Resource': + # Since the token is only used through calls to the service object, + # this ensure that the token is always refreshed before use. + if self.creds.access_token_expired: + self.creds.refresh(Http()) + + return self._service + def send_message( self, sender: str, to: str, subject: str = '', - msg_html: Optional[str] = None, + msg_html: Optional[str] = None, msg_plain: Optional[str] = None, cc: Optional[List[str]] = None, bcc: Optional[List[str]] = None, @@ -121,22 +129,22 @@ def send_message( to: The email address the message is being sent to. subject: The subject line of the email. msg_html: The HTML message of the email. - msg_plain: The plain text alternate message of the email. This is - often displayed on slow or old browsers, or if the HTML message + msg_plain: The plain text alternate message of the email. This is + often displayed on slow or old browsers, or if the HTML message is not provided. cc: The list of email addresses to be cc'd. bcc: The list of email addresses to be bcc'd. attachments: The list of attachment file names. - signature: Whether the account signature should be added to the + signature: Whether the account signature should be added to the message. - user_id: The address of the sending account. 'me' for the + user_id: The address of the sending account. 'me' for the default address associated with the account. Returns: The Message object representing the sent message. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -160,17 +168,17 @@ def get_unread_inbox( user_id: str = 'me', labels: Optional[List[Label]] = None, query: str = '', - attachments: Union['ignore', 'reference', 'download'] = 'reference' + attachments: str = 'reference' ) -> List[Message]: """ Gets unread messages from your inbox. Args: - user_id: The user's email address. By default, the authenticated + user_id: The user's email address. By default, the authenticated user. labels: Labels that messages must match. query: A Gmail query to match. - attachments: Accepted values are 'ignore' which completely + attachments: Accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default @@ -180,7 +188,7 @@ def get_unread_inbox( A list of message objects. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -196,18 +204,18 @@ def get_starred_messages( user_id: str = 'me', labels: Optional[List[Label]] = None, query: str = '', - attachments: Union['ignore', 'reference', 'download'] = 'reference', + attachments: str = 'reference', include_spam_trash: bool = False ) -> List[Message]: """ Gets starred messages from your account. Args: - user_id: The user's email address. By default, the authenticated + user_id: The user's email address. By default, the authenticated user. labels: Label IDs messages must match. query: A Gmail query to match. - attachments: accepted values are 'ignore' which completely + attachments: accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default @@ -218,7 +226,7 @@ def get_starred_messages( A list of message objects. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -235,18 +243,18 @@ def get_important_messages( user_id: str = 'me', labels: Optional[List[Label]] = None, query: str = '', - attachments: Union['ignore', 'reference', 'download'] = 'reference', + attachments: str = 'reference', include_spam_trash: bool = False ) -> List[Message]: """ Gets messages marked important from your account. Args: - user_id: The user's email address. By default, the authenticated + user_id: The user's email address. By default, the authenticated user. labels: Label IDs messages must match. query: A Gmail query to match. - attachments: accepted values are 'ignore' which completely + attachments: accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default @@ -257,16 +265,16 @@ def get_important_messages( A list of message objects. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ - + if labels is None: labels = [] labels.append(label.IMPORTANT) - return self.get_messages(user_id, labels, query, attachments, + return self.get_messages(user_id, labels, query, attachments, include_spam_trash) def get_unread_messages( @@ -274,18 +282,18 @@ def get_unread_messages( user_id: str = 'me', labels: Optional[List[Label]] = None, query: str = '', - attachments: Union['ignore', 'reference', 'download'] = 'reference', + attachments: str = 'reference', include_spam_trash: bool = False ) -> List[Message]: """ Gets unread messages from your account. Args: - user_id: The user's email address. By default, the authenticated + user_id: The user's email address. By default, the authenticated user. labels: Label IDs messages must match. query: A Gmail query to match. - attachments: accepted values are 'ignore' which completely + attachments: accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default @@ -296,11 +304,11 @@ def get_unread_messages( A list of message objects. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ - + if labels is None: labels = [] @@ -313,18 +321,18 @@ def get_drafts( user_id: str = 'me', labels: Optional[List[Label]] = None, query: str = '', - attachments: Union['ignore', 'reference', 'download'] = 'reference', + attachments: str = 'reference', include_spam_trash: bool = False ) -> List[Message]: """ Gets drafts saved in your account. Args: - user_id: The user's email address. By default, the authenticated + user_id: The user's email address. By default, the authenticated user. labels: Label IDs messages must match. query: A Gmail query to match. - attachments: accepted values are 'ignore' which completely + attachments: accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default @@ -335,16 +343,16 @@ def get_drafts( A list of message objects. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ - + if labels is None: labels = [] labels.append(label.DRAFT) - return self.get_messages(user_id, labels, query, attachments, + return self.get_messages(user_id, labels, query, attachments, include_spam_trash) def get_sent_messages( @@ -352,18 +360,18 @@ def get_sent_messages( user_id: str = 'me', labels: Optional[List[Label]] = None, query: str = '', - attachments: Union['ignore', 'reference', 'download'] = 'reference', + attachments: str = 'reference', include_spam_trash: bool = False ) -> List[Message]: """ Gets sent messages from your account. Args: - user_id: The user's email address. By default, the authenticated + user_id: The user's email address. By default, the authenticated user. labels: Label IDs messages must match. query: A Gmail query to match. - attachments: accepted values are 'ignore' which completely + attachments: accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default @@ -374,11 +382,11 @@ def get_sent_messages( A list of message objects. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ - + if labels is None: labels = [] @@ -391,18 +399,18 @@ def get_trash_messages( user_id: str = 'me', labels: Optional[List[Label]] = None, query: str = '', - attachments: Union['ignore', 'reference', 'download'] = 'reference' + attachments: str = 'reference' ) -> List[Message]: """ Gets messages in your trash from your account. Args: - user_id: The user's email address. By default, the authenticated + user_id: The user's email address. By default, the authenticated user. labels: Label IDs messages must match. query: A Gmail query to match. - attachments: accepted values are 'ignore' which completely + attachments: accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default @@ -412,11 +420,11 @@ def get_trash_messages( A list of message objects. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ - + if labels is None: labels = [] @@ -428,17 +436,17 @@ def get_spam_messages( user_id: str = 'me', labels: Optional[List[Label]] = None, query: str = '', - attachments: Union['ignore', 'reference', 'download'] = 'reference' + attachments: str = 'reference' ) -> List[Message]: """ Gets messages marked as spam from your account. Args: - user_id: The user's email address. By default, the authenticated + user_id: The user's email address. By default, the authenticated user. labels: Label IDs messages must match. query: A Gmail query to match. - attachments: accepted values are 'ignore' which completely + attachments: accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default @@ -448,12 +456,12 @@ def get_spam_messages( A list of message objects. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ - - + + if labels is None: labels = [] @@ -465,18 +473,18 @@ def get_messages( user_id: str = 'me', labels: Optional[List[Label]] = None, query: str = '', - attachments: Union['ignore', 'reference', 'download'] = 'reference', + attachments: str = 'reference', include_spam_trash: bool = False ) -> List[Message]: """ Gets messages from your account. Args: - user_id: the user's email address. Default 'me', the authenticated + user_id: the user's email address. Default 'me', the authenticated user. labels: label IDs messages must match. query: a Gmail query to match. - attachments: accepted values are 'ignore' which completely + attachments: accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default @@ -487,11 +495,11 @@ def get_messages( A list of message objects. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. - + """ - + if labels is None: labels = [] @@ -529,27 +537,27 @@ def get_messages( except HttpError as error: # Pass along the error raise error - + def list_labels(self, user_id: str = 'me') -> List[Label]: """ Retrieves all labels for the specified user. - These Label objects are to be used with other functions like + These Label objects are to be used with other functions like modify_labels(). Args: - user_id: The user's email address. By default, the authenticated + user_id: The user's email address. By default, the authenticated user. Returns: The list of Label objects. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ - + try: res = self.service.users().labels().list( userId=user_id @@ -602,7 +610,7 @@ def _get_messages_from_refs( self, user_id: str, message_refs: List[dict], - attachments: Union['ignore', 'reference', 'download'] = 'reference', + attachments: str = 'reference', parallel: bool = True ) -> List[Message]: """ @@ -611,13 +619,13 @@ def _get_messages_from_refs( Args: user_id: The account the messages belong to. message_refs: A list of message references with keys id, threadId. - attachments: Accepted values are 'ignore' which completely ignores + attachments: Accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default 'reference'. parallel: Whether to retrieve messages in parallel. Default true. - Currently parallelization is always on, since there is no + Currently parallelization is always on, since there is no reason to do otherwise. @@ -625,11 +633,11 @@ def _get_messages_from_refs( A list of Message objects. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ - + if not message_refs: return [] @@ -675,16 +683,16 @@ def _build_message_from_ref( self, user_id: str, message_ref: dict, - attachments: Union['ignore', 'reference', 'download'] = 'reference' + attachments: str = 'reference' ) -> Message: """ Creates a Message object from a reference. Args: user_id: The username of the account the message belongs to. - message_ref: The message reference object returned from the Gmail + message_ref: The message reference object returned from the Gmail API. - attachments: Accepted values are 'ignore' which completely ignores + attachments: Accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default @@ -694,7 +702,7 @@ def _build_message_from_ref( The Message object. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -704,7 +712,7 @@ def _build_message_from_ref( message = self.service.users().messages().get( userId=user_id, id=message_ref['id'] ).execute() - + except HttpError as error: # Pass along the error raise error @@ -739,7 +747,7 @@ def _build_message_from_ref( recipient = hdr['value'] elif hdr['name'].lower() == 'subject': subject = hdr['value'] - + msg_hdrs[hdr['name']] = hdr['value'] parts = self._evaluate_message_payload( @@ -766,16 +774,16 @@ def _build_message_from_ref( part['filetype'], part['data']) attms.append(attm) - return Message(self.service, user_id, msg_id, thread_id, recipient, - sender, subject, date, snippet, plain_msg, html_msg, label_ids, - attms, msg_hdrs) + return Message(self.service, self.creds, user_id, msg_id, + thread_id, recipient, sender, subject, date, snippet, + plain_msg, html_msg, label_ids, attms, msg_hdrs) def _evaluate_message_payload( self, payload: dict, user_id: str, msg_id: str, - attachments: Union['ignore', 'reference', 'download'] = 'reference' + attachments: str = 'reference' ) ->List[dict]: """ Recursively evaluates a message payload. @@ -784,7 +792,7 @@ def _evaluate_message_payload( payload: The message payload object (response from Gmail API). user_id: The current account address (default 'me'). msg_id: The id of the message. - attachments: Accepted values are 'ignore' which completely ignores + attachments: Accepted values are 'ignore' which completely ignores all attachments, 'reference' which includes attachment information but does not download the data, and 'download' which downloads the attachment data to store locally. Default @@ -794,7 +802,7 @@ def _evaluate_message_payload( A list of message parts. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -818,7 +826,7 @@ def _evaluate_message_payload( if attachments == 'reference': return [obj] - + else: # attachments == 'download' if 'data' in payload['body']: data = payload['body']['data'] @@ -831,7 +839,7 @@ def _evaluate_message_payload( file_data = base64.urlsafe_b64decode(data) obj['data'] = file_data return [obj] - + elif payload['mimeType'] == 'text/html': data = payload['body']['data'] data = base64.urlsafe_b64decode(data) @@ -857,7 +865,7 @@ def _evaluate_message_payload( def _create_message( self, sender: str, - to: str, + to: str, subject: str = '', msg_html: str = None, msg_plain: str = None, @@ -875,13 +883,13 @@ def _create_message( to: The email address the message is being sent to. subject: The subject line of the email. msg_html: The HTML message of the email. - msg_plain: The plain text alternate message of the email (for slow + msg_plain: The plain text alternate message of the email (for slow or old browsers). cc: The list of email addresses to be Cc'd. bcc: The list of email addresses to be Bcc'd attachments: A list of attachment file paths. - signature: Whether the account signature should be added to the - message. Will add the signature to your HTML message only, or a + signature: Whether the account signature should be added to the + message. Will add the signature to your HTML message only, or a create a HTML message if none exists. Returns: @@ -952,6 +960,8 @@ def _ready_message_with_attachments( main_type, sub_type = content_type.split('/', 1) with open(filepath, 'rb') as file: raw_data = file.read() + + attm: MIMEBase if main_type == 'text': attm = MIMEText(raw_data.decode('UTF-8'), _subtype=sub_type) elif main_type == 'image': @@ -999,7 +1009,7 @@ def _get_alias_info( Args: send_as_email: The alias account information is requested for (could be the primary account). - user_id: The user ID of the authenticated user the account the + user_id: The user ID of the authenticated user the account the alias is for (default "me"). Returns: diff --git a/simplegmail/message.py b/simplegmail/message.py index 5be45dd..2ac4226 100644 --- a/simplegmail/message.py +++ b/simplegmail/message.py @@ -7,6 +7,7 @@ from typing import List, Optional, Union +from httplib2 import Http from googleapiclient.errors import HttpError from simplegmail import label @@ -16,8 +17,8 @@ class Message(object): """ - The Message class for emails in your Gmail mailbox. This class should not - be manually constructed. Contains all information about the associated + The Message class for emails in your Gmail mailbox. This class should not + be manually constructed. Contains all information about the associated message, and can be used to modify the message's labels (e.g., marking as read/unread, archiving, moving to trash, starring, etc.). @@ -51,10 +52,11 @@ class Message(object): attachments (List[Attachment]): a list of attachments for the message. """ - + def __init__( self, service: 'googleapiclient.discovery.Resource', + creds: 'oauth2client.client.OAuth2Credentials', user_id: str, msg_id: str, thread_id: str, @@ -70,6 +72,7 @@ def __init__( headers: Optional[dict] = None ) -> None: self._service = service + self.creds = creds self.user_id = user_id self.id = msg_id self.thread_id = thread_id @@ -84,6 +87,13 @@ def __init__( self.attachments = attachments if attachments is not None else [] self.headers = headers if headers else {} + @property + def service(self) -> 'googleapiclient.discovery.Resource': + if self.creds.access_token_expired: + self.creds.refresh(Http()) + + return self._service + def __repr__(self) -> str: """Represents the object by its sender, recipient, and id.""" @@ -94,9 +104,9 @@ def __repr__(self) -> str: def mark_as_read(self) -> None: """ Marks this message as read (by removing the UNREAD label). - + Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -108,7 +118,7 @@ def mark_as_unread(self) -> None: Marks this message as unread (by adding the UNREAD label). Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -120,7 +130,7 @@ def mark_as_spam(self) -> None: Marks this message as spam (by adding the SPAM label). Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -132,7 +142,7 @@ def mark_as_not_spam(self) -> None: Marks this message as not spam (by removing the SPAM label). Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -144,7 +154,7 @@ def mark_as_important(self) -> None: Marks this message as important (by adding the IMPORTANT label). Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -156,7 +166,7 @@ def mark_as_not_important(self) -> None: Marks this message as not important (by removing the IMPORTANT label). Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -168,7 +178,7 @@ def star(self) -> None: Stars this message (by adding the STARRED label). Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -178,11 +188,11 @@ def star(self) -> None: def unstar(self) -> None: """ Unstars this message (by removing the STARRED label). - + Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. - + """ self.remove_label(label.STARRED) @@ -200,7 +210,7 @@ def archive(self) -> None: Archives the message (removes from inbox by removing the INBOX label). Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -212,7 +222,7 @@ def trash(self) -> None: Moves this message to the trash. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -231,13 +241,13 @@ def trash(self) -> None: f'An error occurred in a call to `trash`.' self.label_ids = res['labelIds'] - + def untrash(self) -> None: """ Removes this message from the trash. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -265,7 +275,7 @@ def move_from_inbox(self, to: Union[Label, str]) -> None: to: The label to move to. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -280,7 +290,7 @@ def add_label(self, to_add: Union[Label, str]) -> None: to_add: The label to add. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -295,11 +305,11 @@ def add_labels(self, to_add: Union[List[Label], List[str]]) -> None: to_add: The list of labels to add. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ - + self.modify_labels(to_add, []) def remove_label(self, to_remove: Union[Label, str]) -> None: @@ -310,13 +320,13 @@ def remove_label(self, to_remove: Union[Label, str]) -> None: to_remove: The label to remove. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ self.remove_labels([to_remove]) - + def remove_labels(self, to_remove: Union[List[Label], List[str]]) -> None: """ Removes the given labels from the message. @@ -325,7 +335,7 @@ def remove_labels(self, to_remove: Union[List[Label], List[str]]) -> None: to_remove: The list of labels to remove. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -339,13 +349,13 @@ def modify_labels( ) -> None: """ Adds or removes the specified label. - + Args: to_add: The label or list of labels to add. to_remove: The label or list of labels to remove. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ @@ -370,7 +380,7 @@ def modify_labels( assert all([lbl in res['labelIds'] for lbl in to_add]) \ and all([lbl not in res['labelIds'] for lbl in to_remove]), \ 'An error occurred while modifying message label.' - + self.label_ids = res['labelIds'] def _create_update_labels( @@ -395,7 +405,7 @@ def _create_update_labels( if to_remove is None: to_remove = [] - + return { 'addLabelIds': [ lbl.id if isinstance(lbl, Label) else lbl for lbl in to_add From 6186e33895f97ef06332f20402ee97feae30563a Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Barenholtz Date: Sat, 23 Oct 2021 23:57:25 -0700 Subject: [PATCH 16/22] Update README.md --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 5349d35..239da79 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,6 @@ A simple Gmail API client in Python for applications. -## New in v3.1.0 - -Message downloading has been multithreaded resulting in significant speedup of downloads! Make sure to upgrade your installation (no other changes are required). - --- Currently Supported Behavior: From f97dcc6ff89321435de068b861d6f5d7a13406f2 Mon Sep 17 00:00:00 2001 From: Jan Janssen Date: Sat, 19 Feb 2022 16:02:16 -0700 Subject: [PATCH 17/22] include LICENSE in pip package (#58) --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) create mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1aba38f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include LICENSE From 8324e7d31f6ac58bb27fd61d5840d23846390cad Mon Sep 17 00:00:00 2001 From: Jan Janssen Date: Sat, 19 Feb 2022 16:04:30 -0700 Subject: [PATCH 18/22] Replace bs4 with beautifulsoup4 (#57) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a191fcf..08ffb0a 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ packages=setuptools.find_packages(), install_requires=[ 'google-api-python-client>=1.7.3', - 'bs4>=0.0.1', + 'beautifulsoup4>=4.0.0', 'python-dateutil>=2.8.1', 'oauth2client>=4.1.3', 'lxml>=4.4.2' From 9f0c4407a9bff414dbb07ea02449437b0f9f8e86 Mon Sep 17 00:00:00 2001 From: Antoine Legrand <2t.antoine@gmail.com> Date: Sun, 20 Feb 2022 00:05:44 +0100 Subject: [PATCH 19/22] Explicitly set an empty argument list to the oauth2client cmdline (#53) Calling simplegmail from another command-line tool using argparse, would pass the current options unrelated of the command to the run_flow call and fail. --- simplegmail/gmail.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/simplegmail/gmail.py b/simplegmail/gmail.py index 221a138..106ec41 100644 --- a/simplegmail/gmail.py +++ b/simplegmail/gmail.py @@ -83,7 +83,8 @@ def __init__( flow = client.flow_from_clientsecrets( self.client_secret_file, self._SCOPES ) - self.creds = tools.run_flow(flow, store) + flags = tools.argparser.parse_args([]) + self.creds = tools.run_flow(flow, store, flags) self._service = build( 'gmail', 'v1', http=self.creds.authorize(Http()), From a865b6f32815d37a4788a8175ff1fdaab3691bc8 Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Date: Mon, 4 Apr 2022 01:12:05 -0700 Subject: [PATCH 20/22] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 239da79..b675921 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ # simplegmail +[![PyPI Downloads](https://img.shields.io/pypi/dm/simplegmail.svg?label=PyPI%20downloads)]( +https://pypi.org/project/simplegmail/) A simple Gmail API client in Python for applications. From 9650656e6c5a2793afc7e171ced4b31421e52077 Mon Sep 17 00:00:00 2001 From: Ross Smith II Date: Mon, 18 Apr 2022 22:44:47 -0700 Subject: [PATCH 21/22] Flip overwrite flag (fixes #67) --- simplegmail/attachment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplegmail/attachment.py b/simplegmail/attachment.py index 079f7c2..689248f 100644 --- a/simplegmail/attachment.py +++ b/simplegmail/attachment.py @@ -97,7 +97,7 @@ def save( if self.data is None: self.download() - if overwrite and os.path.exists(filepath): + if not overwrite and os.path.exists(filepath): raise FileExistsError( f"Cannot overwrite file '{filepath}'. Use overwrite=True if " f"you would like to overwrite the file." From 4f92f1bb43df35c8199175fc257432364acb5fa0 Mon Sep 17 00:00:00 2001 From: Jeremy Ephron Date: Fri, 10 Mar 2023 22:20:54 -0800 Subject: [PATCH 22/22] [feature] Create and delete labels Tidy up PR adding create label functionality and include delete label functionality as well. --- requirements.txt | Bin 1022 -> 0 bytes simplegmail/gmail.py | 56 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 45 insertions(+), 11 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6b29b5d151891ade209ea427c41a4bd7efee7fc0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1022 zcmaJ=(N4lZ5S(WdKc&PTAoAeH2pqJr&_at5KVF^Ly#tJBn!7f4yR$R1+po_BB3^LB zfEsJQ8}w-T?l8eKp9w~obB%G2J1ltX=MOy7D(!qpj3p{!4a{y0C1;1LsE9+VR2zCA zhL&&?H%(|2@eWuSUdHi?VzIFr8&-)Z7}=3~!TK$8qT!ar3V7u1yl-c8IFU`oyq)DY z#EJYK5;tK;<*k}>th%ZtkN%4+7(MXm$hxMF!E#;stxQ+dCtX`?6U&YoTjI3rgq)Gm zDOF~SzpIF$cdMqeJNl-4?~XHXdFHwT-Tdi>=0LUF&yJBxymH@A&5pZcNzdH;y6{{O zb8YuxS(5DtPbua^gr51O-78yle%}_Re`WI-lX69i{Uutg4!pf)_`2x0)p}e7AEs>W+8^^1r8u nh&Qg>Bkz;4kJMNj^Q}#jt5BRzL1wSGCQaM;T+RQAiTUvjjC_{> diff --git a/simplegmail/gmail.py b/simplegmail/gmail.py index 106ec41..41ba2fe 100644 --- a/simplegmail/gmail.py +++ b/simplegmail/gmail.py @@ -572,41 +572,75 @@ def list_labels(self, user_id: str = 'me') -> List[Label]: labels = [Label(name=x['name'], id=x['id']) for x in res['labels']] return labels - def create_label(self, label_name: str, user_id: str = 'me') -> Label: + def create_label( + self, + name: str, + user_id: str = 'me' + ) -> Label: """ - Create a new label + Creates a new label. Args: - label_name: Name for the new label - - user_id: The user's email address. By default, the authenticated + name: The display name of the new label. + user_id: The user's email address. By default, the authenticated user. Returns: - A Label object. + The created Label object. Raises: - googleapiclient.errors.HttpError: There was an error executing the + googleapiclient.errors.HttpError: There was an error executing the HTTP request. """ + body = { - "name": label_name, + "name": name, + + # TODO: In the future, can add the following fields: + # "messageListVisibility" + # "labelListVisibility" + # "color" } - + try: res = self.service.users().labels().create( userId=user_id, body=body ).execute() - + except HttpError as error: # Pass along the error raise error - + else: return Label(res['name'], res['id']) + def delete_label(self, label: Label, user_id: str = 'me') -> None: + """ + Deletes a label. + + Args: + label: The label to delete. + user_id: The user's email address. By default, the authenticated + user. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + + """ + + try: + self.service.users().labels().delete( + userId=user_id, + id=label.id + ).execute() + + except HttpError as error: + # Pass along the error + raise error + def _get_messages_from_refs( self, user_id: str,