",
+ "john@example.com",
+ "mike@example.com"
+ ],
+ "send_each_at": [
+ 1409348513,
+ 1409348514,
+ 1409348515
+ ]
+}
+```
+
+
+# Section Tags
+Section tags are similar to substitution tags in how they’re built, but are specific to the message, not the recipient. You have to have a substitution tag value for each recipient, but you can have any number of section tags. Section tags can then contain Substitution tags for the recipient if needed. Section tags have to be contained within a Substitution tag, since SendGrid needs to know which data to populate for the recipient.
+
+The format of the SMTP API section tag has the form:
+```python
+{
+ "section": {
+ ":sectionName1": "section 1 text",
+ ":sectionName2": "section 2 text"
+ }
+}
+```
+
+
+# Substitution Tags
+
+**This endpoint allows you to easily generate dynamic content for each recipient on your list.**
+
+ When you send to a list of recipients over SMTP API you can specify substitution tags specific to each recipient. For example, a first name that will then be inserted into an opening greeting like the following, where each recipient sees -firstName- replaced with their first name.
+
+`"Dear -firstName-"`
+
+These tags can also be used in more complex scenarios. For example, you could use a -customerID- to build a custom URL that is specific to that user.
+
+A customer specific ID can replace -customerID- in the URL within your email
+`Claim your offer!`
+
+## Substitution Tag Example
+
+Email HTML content:
+```
+
+
+
+ Hello -name-,
+ Thank you for your interest in our products. I have set up an appointment
+ to call you at -time- EST to discuss your needs in more detail. If you would
+ like to reschedule this call please visit the following link:
+ reschedule
+
+ Regards,
+
+ -salesContact-
+ -contactPhoneNumber-
+
+
+
+```
+
+An accompanying SMTP API JSON header might look something like this:
+```
+{
+ "to": [
+ "john.doe@gmail.com",
+ "jane.doe@hotmail.com"
+ ],
+ "sub": {
+ "-name-": [
+ "John",
+ "Jane"
+ ],
+ "-customerID-": [
+ "1234",
+ "5678"
+ ],
+ "-salesContact-": [
+ "Jared",
+ "Ben"
+ ],
+ "-contactPhoneNumber-": [
+ "555.555.5555",
+ "777.777.7777"
+ ],
+ "-time-": [
+ "3:00pm",
+ "5:15pm"
+ ]
+ }
+}
+```
+
+The resulting email for John would look like this:
+```
+
+
+
+ Hello John,
+ Thank you for your interest in our products. I have set up an appointment
+ to call you at 3:00pm EST to discuss your needs in more detail. If you would
+ like to reschedule this call please visit the following link:
+ reschedule
+
+ Regards,
+
+ Jared
+ 555.555.5555
+
+
+
+```
+
+
+# Suppression Groups
+
+## Defining an Unsubscribe Group When Sending
+
+**This endpoint allows you to specify an unsubscribe group for an email depends on how you will be sending that email.**
+
+Precaution:
+
+* When sending an SMTP message, add the group’s ID to the X-SMTPAPI header.
+* When sending an email via the Web API v2, add the group’s ID in the `x-smtpapi` parameter.
+* When sending an email via the Web API v3, define the group’s ID in the `asm.group_id` parameter.
+
+You may only specify one group per send, and you should wait one minute after creating the group before sending with it.
+
+```python
+{
+ "asm_group_id": 1
+}
+```
+
+Defining Unsubscribe Groups to display on the Manage Preferences page
+To specify which groups to display on the Manage Preferences page of an email, add the group IDs to the X-SMTPAPI header of an SMTP message, or in the x-smtpapi parameter of a mail.send API call. If the asm_groups_to_display header is omitted, your default groups will be shown on the Manage Preferences page instead.
+
+You can specify up to 25 groups to display.
+```python
+{
+ "asm_groups_to_display": [1, 2, 3]
+}
+```
+
+## Groups
+You can find your group IDs by looking at the Group ID column in the Unsubscribe Groups UI, or by calling the [GET method](https://sendgrid.com/docs/API_Reference/Web_API_v3/Suppression_Management/groups.html#-GET) of the group's resource.
+
+
+# Unique Arguments
+
+The SMTP API JSON string allows you to attach an unlimited number of unique arguments to your email up to 10,000 bytes. The arguments are used only for tracking. They can be retrieved through the Event API or the Email Activity page.
+
+These arguments can be added using a JSON string like this:
+```
+{
+ "unique_args": {
+ "customerAccountNumber": "55555",
+ "activationAttempt": "1",
+ "New Argument 1": "New Value 1",
+ "New Argument 2": "New Value 2",
+ "New Argument 3": "New Value 3",
+ "New Argument 4": "New Value 4"
+ }
+}
+```
+
+These arguments can then be seen in posts from the SendGrid Event Webhook. The contents of one of these POST requests would look something like this:
+
+## Example Webhook Post Data
+
+```
+{
+ "sg_message_id": "145cea24eb8.1c420.57425.filter-132.3382.5368192A3.0",
+ "New Argument 1": "New Value 1",
+ "event": "processed",
+ "New Argument 4": "New Value 4",
+ "email": "user@example.com",
+ "smtp-id": "<145cea24eb8.1c420.57425@localhost.localdomain>",
+ "timestamp": 1399331116,
+ "New Argument 2": "New Value 2",
+ "New Argument 3": "New Value 3",
+ "customerAccountNumber": "55555",
+ "activationAttempt": "1"
+}
+```
+Unique Arguments will also be shown in the Email Activity tab of your account.
+
+To apply different unique arguments to individual emails, you may use substitution tags. An example of this would look like:
+```
+{
+ "sub": {
+ "-account_number-": [
+ "314159",
+ "271828"
+ ]
+ },
+ "unique_args": {
+ "customerAccountNumber": "-account_number-"
+ }
+}
+```
diff --git a/VERSION.txt b/VERSION.txt
new file mode 100644
index 0000000..75274d8
--- /dev/null
+++ b/VERSION.txt
@@ -0,0 +1 @@
+0.4.12
diff --git a/changes.py b/changes.py
new file mode 100644
index 0000000..79448d5
--- /dev/null
+++ b/changes.py
@@ -0,0 +1,153 @@
+#!/usr/bin/python
+"""
+Small python script that, when run, will update the CHANGELOG with information
+about all merged pull requests since the previous release.
+
+This script must be run after tagging the latest version
+It checks the log of commits since the previous tag and parses it
+"""
+import re
+import subprocess
+import sys
+from datetime import datetime
+
+# Regex patterns
+RELEASE_MD_PATTERN = re.compile(r'## \[(\d+\.\d+\.\d+)\]')
+MERGED_PR_PATTERN = re.compile(
+ r'([0-9a-f]{7}) Merge pull request #(\d+) from (.+)/.+'
+)
+TAG_PATTERN = re.compile(
+ r'refs/tags/v(\d+\.\d+\.\d+) (\w{3} \w{3} \d{1,2} \d{2}:\d{2}:\d{2} \d{4})'
+)
+
+# PR Type terms
+FIX_TERMS = ['fix', 'change', 'update']
+
+
+# Helper functions
+def generate_pr_link(pr_num):
+ """
+ Returns a markdown link to a PR in this repo given its number
+ """
+ return (
+ '[PR #{0}](https://github.com/sendgrid/smtpapi-python/pulls/{0})'
+ ).format(pr_num)
+
+
+def generate_user_link(user):
+ """
+ Returns a markdown link to a user
+ """
+ return '[@{0}](https://github.com/{0})'.format(user)
+
+
+# Get latest tag
+command = ['git', 'tag', '--format=%(refname) %(creatordate)']
+res = subprocess.run(command, capture_output=True, text=True)
+if res.returncode != 0:
+ print('Error occurred when running git tag command:', str(res.stderr))
+ sys.exit(1)
+# Get the last line and get the tag number
+latest_release_match = TAG_PATTERN.match(
+ list(filter(None, res.stdout.split('\n')))[-1],
+)
+latest_release = latest_release_match[1]
+latest_release_date = datetime.strptime(
+ latest_release_match[2], '%a %b %d %H:%M:%S %Y',
+)
+print('Generating CHANGELOG for', latest_release)
+
+# Read in the CHANGELOG file first
+with open('CHANGELOG.md') as f:
+ # Read the text in as a list of lines
+ old_text = f.readlines()
+ # Get the latest release (top of the CHANGELOG)
+ for line in old_text:
+ match = RELEASE_MD_PATTERN.match(line)
+ if match:
+ prev_release = match[1]
+ break
+
+if latest_release == prev_release:
+ print(
+ 'The latest git tag matches the last release in the CHANGELOG. '
+ 'Please tag the repository before running this script.'
+ )
+ sys.exit(1)
+
+# Use git log to list all commits between that tag and HEAD
+command = 'git log --oneline v{}..@'.format(prev_release).split(' ')
+res = subprocess.run(command, capture_output=True, text=True)
+if res.returncode != 0:
+ print('Error occurred when running git log command:', str(res.stderr))
+ sys.exit(1)
+
+# Parse the output from the above command to find all commits for merged PRs
+merge_commits = []
+for line in res.stdout.split('\n'):
+ match = MERGED_PR_PATTERN.match(line)
+ if match:
+ merge_commits.append(match)
+
+# Determine the type of PR from the commit message
+added, fixes = [], []
+for commit in merge_commits:
+ # Get the hash of the commit and get the message of it
+ commit_sha = commit[1]
+ command = 'git show {} --format=format:%B'.format(commit_sha).split(' ')
+ res = subprocess.run(command, capture_output=True, text=True)
+ out = res.stdout.lower()
+ is_added = True
+
+ # When storing we need the PR title, number and user
+ data = {
+ # 3rd line of the commit message is the PR title
+ 'title': out.split('\n')[2],
+ 'number': commit[2],
+ 'user': commit[3],
+ }
+
+ for term in FIX_TERMS:
+ if term in out:
+ fixes.append(data)
+ is_added = False
+ break
+ if is_added:
+ added.append(data)
+
+# Now we need to write out the CHANGELOG again
+with open('CHANGELOG.md', 'w') as f:
+ # Write out the header lines first
+ for i in range(0, 3):
+ f.write(old_text[i])
+
+ # Create and write out the new version information
+ latest_release_date_string = latest_release_date.strftime('%Y-%m-%d')
+ f.write('## [{}] - {} ##\n'.format(
+ latest_release,
+ latest_release_date_string,
+ ))
+ # Add the stuff that was added
+ f.write('### Added\n')
+ for commit in added:
+ f.write('- {}: {}{} (via {})\n'.format(
+ generate_pr_link(commit['number']),
+ commit['title'],
+ '.' if commit['title'][-1] != '.' else '',
+ generate_user_link(commit['user'])
+ ))
+ f.write('\n')
+ # Add the fixes
+ f.write('### Fixes\n')
+ for commit in fixes:
+ f.write('- {}: {}{} (via {})\n'.format(
+ generate_pr_link(commit['number']),
+ commit['title'],
+ '.' if commit['title'][-1] != '.' else '',
+ generate_user_link(commit['user'])
+ ))
+ f.write('\n')
+
+ # Add the old stuff
+ for i in range(3, len(old_text)):
+ f.write(old_text[i])
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..64cc37b
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,6 @@
+version: '3'
+services:
+ web:
+ build: .
+ volumes:
+ - .:/root
diff --git a/examples/example.py b/examples/example.py
new file mode 100644
index 0000000..07c3b15
--- /dev/null
+++ b/examples/example.py
@@ -0,0 +1,53 @@
+# Python 2/3 compatible codebase
+from __future__ import absolute_import, division, print_function
+from smtpapi import SMTPAPIHeader
+
+import time
+from os import path, sys
+
+if __name__ == '__main__' and __package__ is None:
+ sys.path.append(path.dirname(path.dirname(path.abspath(__file__))))
+
+header = SMTPAPIHeader()
+
+# [To](http://sendgrid.com/docs/API_Reference/SMTP_API/index.html)
+# header.add_to('test@example.com')
+header.set_tos(['test1@example.com', 'test2@example.com'])
+
+# [Substitutions]
+# (http://sendgrid.com/docs/API_Reference/SMTP_API/substitution_tags.html)
+# header.add_substitution('key', 'value')
+header.set_substitutions({'key': ['value1', 'value2']})
+
+# [Unique Arguments]
+# (http://sendgrid.com/docs/API_Reference/SMTP_API/unique_arguments.html)
+# header.add_unique_arg('key', 'value')
+header.set_unique_args({'key': 'value'})
+
+# [Categories](http://sendgrid.com/docs/API_Reference/SMTP_API/categories.html)
+# header.add_category('category')
+header.set_categories(['category1', 'category2'])
+
+# [Sections](http://sendgrid.com/docs/API_Reference/SMTP_API/section_tags.html)
+# header.add_section('key', 'section')
+header.set_sections({'key1': 'section1', 'key2': 'section2'})
+
+# [Filters]
+# (http://sendgrid.com/docs/API_Reference/SMTP_API/apps.html)
+header.add_filter('filter', 'setting', 'value')
+
+# [ASM Group ID]
+# (https://sendgrid.com/docs/User_Guide/advanced_suppression_manager.html)
+header.set_asm_group_id('value')
+
+# [IP Pools]
+# (https://sendgrid.com/docs/API_Reference/Web_API_v3/IP_Management/ip_pools.html)
+header.set_ip_pool("testPool")
+
+# [Scheduling Parameters]
+# (https://sendgrid.com/docs/API_Reference/SMTP_API/scheduling_parameters.html)
+# header.add_send_each_at(unix_timestamp) # must be a unix timestamp
+# header.set_send_each_at([]) # must be a unix timestamp
+header.set_send_at(int(time.time())) # must be a unix timestamp
+
+print(header.json_string())
diff --git a/run.sh b/run.sh
new file mode 100644
index 0000000..920b790
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+
+python2.7 setup.py install
+python2.7 test/__init__.py
diff --git a/setup.py b/setup.py
index 81f015d..1777899 100644
--- a/setup.py
+++ b/setup.py
@@ -1,12 +1,37 @@
+import io
+import os
+from distutils.file_util import copy_file
from setuptools import setup, find_packages
+
+dir_path = os.path.abspath(os.path.dirname(__file__))
+readme = io.open(os.path.join(dir_path, 'README.rst'), encoding='utf-8').read()
+version = io.open(
+ os.path.join(dir_path, 'VERSION.txt'),
+ encoding='utf-8',
+).read().strip()
+copy_file(os.path.join(dir_path, 'VERSION.txt'),
+ os.path.join(dir_path, 'smtpapi', 'VERSION.txt'),
+ verbose=0)
setup(
name='smtpapi',
- version='0.3.1',
+ version=version,
author='Yamil Asusta, Kane Kim',
author_email='yamil@sendgrid.com, kane.isturm@sendgrid.com',
- packages=find_packages(),
+ url='https://github.com/sendgrid/smtpapi-python/',
+ packages=find_packages(exclude=["test"]),
+ include_package_data=True,
license='MIT License',
description='Simple wrapper to use SendGrid SMTP API',
- long_description='Simple wrapper to use SendGrid SMTP API',
+ long_description=readme,
+ classifiers=[
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
+ 'Programming Language :: Python :: 3.7',
+ 'Programming Language :: Python :: 3.8',
+ 'Programming Language :: Python :: 3.9',
+ 'Programming Language :: Python :: 3.10',
+ ],
)
diff --git a/smtpapi/__init__.py b/smtpapi/__init__.py
index 43cbb59..1ed55a7 100644
--- a/smtpapi/__init__.py
+++ b/smtpapi/__init__.py
@@ -1,13 +1,17 @@
-import json, decimal
+import json
+import decimal
+
class _CustomJSONEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, decimal.Decimal):
return float(o)
- # Provide a fallback to the default encoder if we haven't implemented special support for the object's class
+ # Provide a fallback to the default encoder if we haven't implemented
+ # special support for the object's class
return super(_CustomJSONEncoder, self).default(o)
+
class SMTPAPIHeader(object):
def __init__(self):
@@ -34,40 +38,42 @@ def add_substitution(self, key, value):
def set_substitutions(self, subs):
self.data['sub'] = subs
+ def _add_key_value(self, index, key, value):
+ if index not in self.data:
+ self.data[index] = {}
+ self.data[index][key] = value
+
+ def _add_key(self, index, key):
+ if index not in self.data:
+ self.data[index] = []
+ self.data[index].append(key)
+
def add_unique_arg(self, key, value):
- if 'unique_args' not in self.data:
- self.data['unique_args'] = {}
- self.data['unique_args'][key] = value
+ self._add_key_value('unique_args', key, value)
def set_unique_args(self, value):
self.data['unique_args'] = value
def add_category(self, category):
- if 'category' not in self.data:
- self.data['category'] = []
- self.data['category'].append(category)
+ self._add_key('category', category)
def set_categories(self, category):
self.data['category'] = category
def add_section(self, key, section):
- if 'section' not in self.data:
- self.data['section'] = {}
- self.data['section'][key] = section
+ self._add_key_value('section', key, section)
def set_sections(self, value):
self.data['section'] = value
def add_send_each_at(self, time):
- if 'send_each_at' not in self.data:
- self.data['send_each_at'] = []
- self.data['send_each_at'].append(time)
+ self._add_key('send_each_at', time)
def set_send_each_at(self, time):
- self.data['send_each_at'] = time
+ self.data['send_each_at'] = time
def set_send_at(self, time):
- self.data['send_at'] = time
+ self.data['send_at'] = time
def add_filter(self, app, setting, val):
if 'filters' not in self.data:
diff --git a/static/img/github-fork.png b/static/img/github-fork.png
new file mode 100644
index 0000000..3c2335f
Binary files /dev/null and b/static/img/github-fork.png differ
diff --git a/static/img/github-sign-up.png b/static/img/github-sign-up.png
new file mode 100644
index 0000000..08766f7
Binary files /dev/null and b/static/img/github-sign-up.png differ
diff --git a/test/__init__.py b/test/__init__.py
index 315fc88..417fadc 100644
--- a/test/__init__.py
+++ b/test/__init__.py
@@ -1,6 +1,16 @@
-import unittest, json, decimal
+import decimal
+import json
+import os
+import datetime
+
from smtpapi import SMTPAPIHeader
+try:
+ import unittest2 as unittest
+except ImportError:
+ import unittest
+
+
class TestSMTPAPI(unittest.TestCase):
def setUp(self):
@@ -40,7 +50,11 @@ def test_add(self):
def test_set(self):
header = SMTPAPIHeader()
- header.set_tos(["test@email.com", "test2@email.com", "test3@email.com"])
+ header.set_tos([
+ "test@email.com",
+ "test2@email.com",
+ "test3@email.com",
+ ])
header.set_substitutions({
"subKey": ["subValue"],
"decimalKey": [decimal.Decimal("1.23456789")]
@@ -71,6 +85,59 @@ def test_drop_empty(self):
header.add_filter('testFilter', 'filter', 'filterValue')
self.assertEqual(self.dropsHeader, json.loads(header.json_string()))
+ def test_license_year(self):
+ LICENSE_FILE = 'LICENSE'
+ copyright_line = ''
+ with open(LICENSE_FILE, 'r') as f:
+ for line in f:
+ if line.startswith('Copyright'):
+ copyright_line = line.strip()
+ break
+ self.assertEqual(
+ 'Copyright (C) %s, Twilio SendGrid, Inc. '
+ % datetime.datetime.now().year,
+ copyright_line
+ )
+
+
+class TestRepository(unittest.TestCase):
+
+ def setUp(self):
+
+ self.required_files = [
+ './Dockerfile',
+ './.env_sample',
+ './PULL_REQUEST_TEMPLATE.md',
+ './.gitignore',
+ './CHANGELOG.md',
+ './CODE_OF_CONDUCT.md',
+ './CONTRIBUTING.md',
+ './LICENSE',
+ './README.rst',
+ './TROUBLESHOOTING.md',
+ './USAGE.md',
+ './VERSION.txt',
+ ]
+
+ self.file_not_found_message = 'File "{0}" does not exist in repo!'
+
+ def test_repository_files_exists(self):
+
+ for file_path in self.required_files:
+ if isinstance(file_path, list):
+ # multiple file paths: assert that any one of the files exists
+ self.assertTrue(
+ any(os.path.exists(f) for f in file_path),
+ msg=self.file_not_found_message.format(
+ '" or "'.join(file_path)
+ ),
+ )
+ else:
+ self.assertTrue(
+ os.path.exists(file_path),
+ msg=self.file_not_found_message.format(file_path),
+ )
+
if __name__ == '__main__':
unittest.main()
diff --git a/test/requirements.txt b/test/requirements.txt
new file mode 100644
index 0000000..89035cf
--- /dev/null
+++ b/test/requirements.txt
@@ -0,0 +1,3 @@
+sendgrid
+coverage
+flake8
diff --git a/test/test_project.py b/test/test_project.py
new file mode 100644
index 0000000..888cc41
--- /dev/null
+++ b/test/test_project.py
@@ -0,0 +1,69 @@
+import os
+import unittest
+
+
+class ProjectTests(unittest.TestCase):
+
+ # ./Docker or docker/Docker
+ def test_docker_dir(self):
+ self.assertTrue(
+ os.path.isfile("./Dockerfile")
+ or os.path.isdir("./docker/Dockerfile")
+ )
+
+ # ./docker-compose.yml or ./docker/docker-compose.yml
+ def test_docker_compose(self):
+ self.assertTrue(
+ os.path.isfile('./docker-compose.yml')
+ or os.path.isfile('./docker/docker-compose.yml')
+ )
+
+ # ./.env_sample
+ def test_env(self):
+ self.assertTrue(os.path.isfile('./.env_sample'))
+
+ # ./.gitignore
+ def test_gitignore(self):
+ self.assertTrue(os.path.isfile('./.gitignore'))
+
+ # ./CHANGELOG.md
+ def test_changelog(self):
+ self.assertTrue(os.path.isfile('./CHANGELOG.md'))
+
+ # ./CODE_OF_CONDUCT.md
+ def test_code_of_conduct(self):
+ self.assertTrue(os.path.isfile('./CODE_OF_CONDUCT.md'))
+
+ # ./CONTRIBUTING.md
+ def test_contributing(self):
+ self.assertTrue(os.path.isfile('./CONTRIBUTING.md'))
+
+ # ./LICENSE
+ def test_license(self):
+ self.assertTrue(os.path.isfile('./LICENSE'))
+
+ # ./PULL_REQUEST_TEMPLATE.md
+ def test_pr_template(self):
+ self.assertTrue(
+ os.path.isfile('./PULL_REQUEST_TEMPLATE.md')
+ )
+
+ # ./README.rst
+ def test_readme(self):
+ self.assertTrue(os.path.isfile('./README.rst'))
+
+ # ./TROUBLESHOOTING.md
+ def test_troubleshooting(self):
+ self.assertTrue(os.path.isfile('./TROUBLESHOOTING.md'))
+
+ # ./USAGE.md
+ def test_usage(self):
+ self.assertTrue(os.path.isfile('./USAGE.md'))
+
+ # ./VERSION.txt
+ def test_use_cases(self):
+ self.assertTrue(os.path.isfile('./VERSION.txt'))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/twilio_sendgrid_logo.png b/twilio_sendgrid_logo.png
new file mode 100644
index 0000000..a4c2223
Binary files /dev/null and b/twilio_sendgrid_logo.png differ
diff --git a/use_cases/README.md b/use_cases/README.md
new file mode 100644
index 0000000..4d00af9
--- /dev/null
+++ b/use_cases/README.md
@@ -0,0 +1,3 @@
+This directory provides examples for specific use cases. Please [open an issue](https://github.com/sendgrid/smtpapi-python/issues) or make a pull request for any use cases you would like to see here. Thank you!
+
+# Table of Contents