diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py
index d67c4e6c6b82a05bfe9d19e5b9ae8560c5efc8c2..ba1fe06e72e9bdf3301dc3f7e6a9d96ca60f9be1 100644
--- a/lms/djangoapps/courseware/courses.py
+++ b/lms/djangoapps/courseware/courses.py
@@ -5,7 +5,7 @@ courseware.
 
 
 import logging
-from collections import defaultdict
+from collections import defaultdict, namedtuple
 from datetime import datetime
 
 import pytz
@@ -63,6 +63,10 @@ from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITIO
 log = logging.getLogger(__name__)
 
 
+# Used by get_course_assignments below. You shouldn't need to use this type directly.
+_Assignment = namedtuple('Assignment', ['block_key', 'title', 'url', 'date', 'requires_full_access'])
+
+
 def get_course(course_id, depth=0):
     """
     Given a course id, return the corresponding course descriptor.
@@ -415,7 +419,7 @@ def get_course_date_blocks(course, user, request=None, include_access=False,
     blocks = [cls(course, user) for cls in block_classes]
     if RELATIVE_DATES_FLAG.is_enabled(course.id):
         blocks.append(CourseExpiredDate(course, user))
-        blocks.extend(get_course_assignment_due_dates(
+        blocks.extend(get_course_assignment_date_blocks(
             course, user, request, num_return=num_assignments,
             include_access=include_access, include_past_dates=include_past_dates,
         ))
@@ -431,45 +435,61 @@ def date_block_key_fn(block):
     return block.date or datetime.max.replace(tzinfo=pytz.UTC)
 
 
-def get_course_assignment_due_dates(course, user, request, num_return=None,
-                                    include_past_dates=False, include_access=False):
+def get_course_assignment_date_blocks(course, user, request, num_return=None,
+                                      include_past_dates=False, include_access=False):
     """
     Returns a list of assignment (at the subsection/sequential level) due date
     blocks for the given course. Will return num_return results or all results
     if num_return is None in date increasing order.
     """
-    store = modulestore()
-    all_course_dates = get_dates_for_course(course.id, user)
     date_blocks = []
-    for (block_key, date_type), date in all_course_dates.items():
-        if date_type == 'due' and block_key.block_type == 'sequential':
-            try:
-                item = store.get_item(block_key)
-            except ItemNotFoundError:
-                continue
-            if item.graded:
-                date_block = CourseAssignmentDate(course, user)
-                date_block.date = date
-
-                if include_access:
-                    date_block.requires_full_access = _requires_full_access(store, user, block_key)
-
-                block_url = None
-                now = datetime.now().replace(tzinfo=pytz.UTC)
-                assignment_released = item.start < now if item.start else True
-                if assignment_released:
-                    block_url = reverse('jump_to', args=[course.id, block_key])
-                    block_url = request.build_absolute_uri(block_url) if request else None
-                assignment_title = item.display_name if item.display_name else _('Assignment')
-                date_block.set_title(assignment_title, link=block_url)
-
-                date_blocks.append(date_block)
+    for assignment in get_course_assignments(course.id, user, request, include_access=include_access):
+        date_block = CourseAssignmentDate(course, user)
+        date_block.date = assignment.date
+        date_block.requires_full_access = assignment.requires_full_access
+        date_block.set_title(assignment.title, link=assignment.url)
+        date_blocks.append(date_block)
     date_blocks = sorted((b for b in date_blocks if b.is_enabled or include_past_dates), key=date_block_key_fn)
     if num_return:
         return date_blocks[:num_return]
     return date_blocks
 
 
+def get_course_assignments(course_key, user, request, include_access=False):
+    """
+    Returns a list of assignment (at the subsection/sequential level) due dates for the given course.
+
+    Each returned object is a namedtuple with fields: block_key, title, url, date, requires_full_access
+    """
+    store = modulestore()
+    all_course_dates = get_dates_for_course(course_key, user)
+    assignments = []
+    for (block_key, date_type), date in all_course_dates.items():
+        if date_type != 'due' or block_key.block_type != 'sequential':
+            continue
+
+        try:
+            item = store.get_item(block_key)
+        except ItemNotFoundError:
+            continue
+
+        if not item.graded:
+            continue
+
+        requires_full_access = include_access and _requires_full_access(store, user, block_key)
+        title = item.display_name or _('Assignment')
+
+        url = None
+        assignment_released = not item.start or item.start < datetime.now(pytz.UTC)
+        if assignment_released:
+            url = reverse('jump_to', args=[course_key, block_key])
+            url = request and request.build_absolute_uri(url)
+
+        assignments.append(_Assignment(block_key, title, url, date, requires_full_access))
+
+    return assignments
+
+
 def _requires_full_access(store, user, block_key):
     """
     Returns a boolean if any child of the block_key specified has a group_access array consisting of just full_access
diff --git a/openedx/features/calendar_sync/__init__.py b/openedx/features/calendar_sync/__init__.py
index f83d0001c51004ef20ed170d796acc8cb0e1ddd8..77fd2dd0b26ef7543f63d52b464fd5906d21f7e5 100644
--- a/openedx/features/calendar_sync/__init__.py
+++ b/openedx/features/calendar_sync/__init__.py
@@ -3,7 +3,7 @@ Calendar syncing Course dates with a User.
 """
 
 
-def get_calendar_event_id(user, block_key, date_type):
+def get_calendar_event_id(user, block_key, date_type, hostname):
     """
     Creates a unique event id based on a user and a course block key
 
@@ -11,7 +11,8 @@ def get_calendar_event_id(user, block_key, date_type):
         user (User): The user requesting a calendar event
         block_key (str): The block key containing the date for the calendar event
         date_type (str): The type of the date (e.g. 'due', 'start', 'end', etc.)
+        hostname (str): A hostname to namespace this id (e.g. 'open.edx.org')
     Returns:
         event id (str)
     """
-    return user.username + '.' + block_key + '.' + date_type
+    return '{}.{}.{}@{}'.format(user.id, block_key, date_type, hostname)
diff --git a/openedx/features/calendar_sync/ics.py b/openedx/features/calendar_sync/ics.py
new file mode 100644
index 0000000000000000000000000000000000000000..d36078181cf4fad639999a2e5467eb0ddd670887
--- /dev/null
+++ b/openedx/features/calendar_sync/ics.py
@@ -0,0 +1,73 @@
+""" Generate .ics files from a user schedule """
+
+from datetime import datetime, timedelta
+
+import pytz
+from django.conf import settings
+from icalendar import Calendar, Event, vCalAddress, vText
+
+from lms.djangoapps.courseware.courses import get_course_assignments
+from openedx.core.djangoapps.site_configuration.helpers import get_value
+from openedx.core.djangolib.markup import HTML
+
+from . import get_calendar_event_id
+
+
+def generate_ics_for_event(uid, summary, url, now, start, organizer_name, organizer_email):
+    """
+    Generates an ics-formatted bytestring for the given assignment information.
+
+    To pretty-print the bytestring, do: `ics.decode('utf8').replace('\r\n', '\n')`
+    """
+    # icalendar library: https://icalendar.readthedocs.io/en/latest/
+    # ics format spec: https://tools.ietf.org/html/rfc2445
+    # ics conventions spec: https://tools.ietf.org/html/rfc5546
+
+    organizer = vCalAddress('mailto:' + organizer_email)
+    organizer.params['cn'] = vText(organizer_name)
+
+    event = Event()
+    event.add('uid', uid)
+    event.add('dtstamp', now)
+    event.add('organizer', organizer, encode=0)
+    event.add('summary', summary)
+    # FIXME description should be translated if we use hardcoded text, once we finalize that text
+    event.add('description', HTML('<a href="{url}">Link</a>').format(url=url))
+    event.add('dtstart', start)
+    event.add('duration', timedelta(0))
+    event.add('transp', 'TRANSPARENT')  # available, rather than busy
+
+    cal = Calendar()
+    cal.add('prodid', '-//Open edX//calendar_sync//EN')
+    cal.add('version', '2.0')
+    cal.add('method', 'REQUEST')
+    cal.add_component(event)
+
+    return cal.to_ical()
+
+
+def generate_ics_for_user_course(course_key, user, request):
+    """
+    Generates ics-formatted bytestrings of all assignments for a given course and user.
+
+    To pretty-print each bytestring, do: `ics.decode('utf8').replace('\r\n', '\n')`
+
+    Returns an iterable of ics files, each one representing an assignment.
+    """
+    assignments = get_course_assignments(course_key, user, request)
+    platform_name = get_value('platform_name', settings.PLATFORM_NAME)
+    platform_email = get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
+    now = datetime.now(pytz.utc)
+
+    return (
+        generate_ics_for_event(
+            now=now,
+            organizer_name=platform_name,
+            organizer_email=platform_email,
+            start=assignment.date,
+            summary=assignment.title,
+            uid=get_calendar_event_id(user, str(assignment.block_key), 'due', request.site.domain),
+            url=assignment.url,
+        )
+        for assignment in assignments
+    )
diff --git a/openedx/features/calendar_sync/tests/test_ics.py b/openedx/features/calendar_sync/tests/test_ics.py
new file mode 100644
index 0000000000000000000000000000000000000000..9d70b38ea2e0d4c61b17e47bb4a996ab57ea80e1
--- /dev/null
+++ b/openedx/features/calendar_sync/tests/test_ics.py
@@ -0,0 +1,94 @@
+""" Tests for the Calendar Sync .ics methods """
+
+from datetime import datetime, timedelta
+
+import pytz
+from django.test import RequestFactory, TestCase
+from freezegun import freeze_time
+from mock import patch
+
+from lms.djangoapps.courseware.courses import _Assignment
+from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
+from openedx.features.calendar_sync import get_calendar_event_id
+from openedx.features.calendar_sync.ics import generate_ics_for_user_course
+from student.tests.factories import UserFactory
+
+
+class TestIcsGeneration(TestCase):
+    """ Test icalendar file generator """
+    def setUp(self):
+        super().setUp()
+
+        freezer = freeze_time(datetime(2013, 10, 3, 8, 24, 55, tzinfo=pytz.utc))
+        self.addCleanup(freezer.stop)
+        freezer.start()
+
+        self.user = UserFactory()
+        self.request = RequestFactory().request()
+        self.request.site = SiteFactory()
+        self.request.user = self.user
+
+    def make_assigment(self, block_key=None, title=None, url=None, date=None, requires_file_access=False):
+        """ Bundles given info into a namedtupled like get_course_assignments returns """
+        return _Assignment(block_key, title, url, date, requires_file_access)
+
+    def expected_ics(self, *assignments):
+        """ Returns hardcoded expected ics strings for given assignments """
+        template = '''BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Open edX//calendar_sync//EN
+METHOD:REQUEST
+BEGIN:VEVENT
+SUMMARY:{summary}
+DTSTART;VALUE=DATE-TIME:{timedue}
+DURATION:P0D
+DTSTAMP;VALUE=DATE-TIME:20131003T082455Z
+UID:{uid}
+DESCRIPTION:<a href="{url}">Link</a>
+ORGANIZER;CN=édX:mailto:registration@example.com
+TRANSP:TRANSPARENT
+END:VEVENT
+END:VCALENDAR
+'''
+        return (
+            template.format(
+                summary=assignment.title,
+                timedue=assignment.date.strftime('%Y%m%dT%H%M%SZ'),
+                url=assignment.url,
+                uid=get_calendar_event_id(self.user, str(assignment.block_key), 'due', self.request.site.domain),
+            )
+            for assignment in assignments
+        )
+
+    def generate_ics(self, *assignments):
+        """ Uses generate_ics_for_user_course to create ics files for the given assignments """
+        with patch('openedx.features.calendar_sync.ics.get_course_assignments') as mock_get_assignments:
+            mock_get_assignments.return_value = assignments
+            return generate_ics_for_user_course('a/b/c', self.user, self.request)
+
+    def assert_ics(self, *assignments):
+        """ Asserts that the generated and expected ics for the given assignments are equal """
+        generated = [ics.decode('utf8').replace('\r\n', '\n') for ics in self.generate_ics(*assignments)]
+        self.assertEqual(len(generated), len(assignments))
+        self.assertListEqual(generated, list(self.expected_ics(*assignments)))
+
+    def test_generate_ics_for_user_course(self):
+        """ Tests that a simple sample set of course assignments is generated correctly """
+        now = datetime.now(pytz.utc)
+        day1 = now + timedelta(1)
+        day2 = now + timedelta(1)
+
+        self.assert_ics(
+            self.make_assigment(
+                block_key='block1',
+                title='Block1',
+                url='https://example.com/block1',
+                date=day1,
+            ),
+            self.make_assigment(
+                block_key='block2',
+                title='Block2',
+                url='https://example.com/block2',
+                date=day2,
+            ),
+        )
diff --git a/openedx/features/calendar_sync/tests/test_init.py b/openedx/features/calendar_sync/tests/test_init.py
index 79e2584ff175cafa899a8bcf356395192bb10314..6722abfb9e0655f75bcac41e1d6e7016e64b1018 100644
--- a/openedx/features/calendar_sync/tests/test_init.py
+++ b/openedx/features/calendar_sync/tests/test_init.py
@@ -18,8 +18,9 @@ class TestCalendarSyncInit(TestCase):
     def test_get_calendar_event_id(self):
         block_key = 'block-v1:Org+Number+Term+type@sequential+block@gibberish'
         date_type = 'due'
-        event_id = get_calendar_event_id(self.user, block_key, date_type)
-        expected = '{username}.{block_key}.{date_type}'.format(
-            username=self.user.username, block_key=block_key, date_type=date_type
+        hostname = 'example.com'
+        event_id = get_calendar_event_id(self.user, block_key, date_type, hostname)
+        expected = '{user_id}.{block_key}.{date_type}@{hostname}'.format(
+            user_id=self.user.id, block_key=block_key, date_type=date_type, hostname=hostname
         )
         self.assertEqual(event_id, expected)
diff --git a/requirements/edx/base.in b/requirements/edx/base.in
index fba36eff396a550614d85b99aa064b6965998c22..8cc6ae1b8b488b19d8e50e9dabb18db5d98acaee 100644
--- a/requirements/edx/base.in
+++ b/requirements/edx/base.in
@@ -94,6 +94,7 @@ glob2                               # Enhanced glob module, used in openedx.core
 gunicorn
 help-tokens
 html5lib                            # HTML parser, used for capa problems
+icalendar                           # .ics generator, used by calendar_sync
 ipaddress                           # Ip network support for Embargo feature
 jsonfield2                          # Django model field for validated JSON; used in several apps
 laboratory                          # Library for testing that code refactors/infrastructure changes produce identical results
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 8059ce099c2bdf278e97f2c58740a732f34bf31c..de70a90c5ab0c9bd339ffca4ee1bb6a1779087af 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -130,6 +130,7 @@ gunicorn==20.0.4          # via -r requirements/edx/base.in
 help-tokens==1.0.5        # via -r requirements/edx/base.in
 html5lib==1.0.1           # via -r requirements/edx/base.in, ora2
 httplib2==0.17.0          # via oauth2
+icalendar==4.0.4          # via -r requirements/edx/base.in
 idna==2.9                 # via -r requirements/edx/paver.txt, requests
 importlib-metadata==1.5.0  # via -r requirements/edx/paver.txt, path
 inflection==0.3.1         # via drf-yasg
@@ -193,14 +194,14 @@ pymongo==3.9.0            # via -r requirements/edx/base.in, -r requirements/edx
 pynliner==0.8.0           # via -r requirements/edx/base.in
 pyparsing==2.2.0          # via chem, openedx-calc, packaging, pycontracts
 pysrt==1.1.2              # via -r requirements/edx/base.in, edxval
-python-dateutil==2.4.0    # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, ora2, xblock
+python-dateutil==2.4.0    # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, icalendar, ora2, xblock
 python-levenshtein==0.12.0  # via -r requirements/edx/base.in
 python-memcached==1.59    # via -r requirements/edx/paver.txt
 python-slugify==4.0.0     # via code-annotations
 python-swiftclient==3.9.0  # via ora2
 python3-openid==3.1.0 ; python_version >= "3"  # via -r requirements/edx/base.in, social-auth-core
 python3-saml==1.5.0       # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in
-pytz==2019.3              # via -r requirements/edx/base.in, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, ora2, xblock
+pytz==2019.3              # via -r requirements/edx/base.in, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, icalendar, ora2, xblock
 pyuca==1.2                # via -r requirements/edx/base.in
 pyyaml==5.3               # via -r requirements/edx/base.in, code-annotations, edx-django-release-util, edx-i18n-tools, xblock
 random2==1.0.1            # via -r requirements/edx/base.in
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index 3eeb083ab2b685d256c8cc3afa45b134c279bc37..c124b0b55c5a9eae986a1e21d13011ef2f339dd3 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -153,6 +153,7 @@ help-tokens==1.0.5        # via -r requirements/edx/testing.txt
 html5lib==1.0.1           # via -r requirements/edx/testing.txt, ora2
 httplib2==0.17.0          # via -r requirements/edx/testing.txt, oauth2
 httpretty==0.9.7          # via -r requirements/edx/testing.txt
+icalendar==4.0.4          # via -r requirements/edx/testing.txt
 idna==2.9                 # via -r requirements/edx/testing.txt, requests
 imagesize==1.2.0          # via sphinx
 importlib-metadata==1.5.0  # via -r requirements/edx/testing.txt, importlib-resources, inflect, jsonschema, path, pluggy, pytest, pytest-randomly, tox, virtualenv
@@ -250,14 +251,14 @@ pytest-metadata==1.8.0    # via -r requirements/edx/testing.txt, pytest-json-rep
 pytest-randomly==3.2.1    # via -r requirements/edx/testing.txt
 pytest-xdist==1.31.0      # via -r requirements/edx/testing.txt
 pytest==5.4.1             # via -r requirements/edx/testing.txt, pytest-attrib, pytest-cov, pytest-django, pytest-forked, pytest-json-report, pytest-metadata, pytest-randomly, pytest-xdist
-python-dateutil==2.4.0    # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, faker, freezegun, ora2, pandas, xblock
+python-dateutil==2.4.0    # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, faker, freezegun, icalendar, ora2, pandas, xblock
 python-levenshtein==0.12.0  # via -r requirements/edx/testing.txt
 python-memcached==1.59    # via -r requirements/edx/testing.txt
 python-slugify==4.0.0     # via -r requirements/edx/testing.txt, code-annotations, transifex-client
 python-swiftclient==3.9.0  # via -r requirements/edx/testing.txt, ora2
 python3-openid==3.1.0 ; python_version >= "3"  # via -r requirements/edx/testing.txt, social-auth-core
 python3-saml==1.5.0       # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt
-pytz==2019.3              # via -r requirements/edx/testing.txt, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, ora2, pandas, xblock
+pytz==2019.3              # via -r requirements/edx/testing.txt, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, icalendar, ora2, pandas, xblock
 pyuca==1.2                # via -r requirements/edx/testing.txt
 pyyaml==5.3               # via -r requirements/edx/testing.txt, code-annotations, edx-django-release-util, edx-i18n-tools, sphinxcontrib-openapi, xblock
 radon==4.1.0              # via -r requirements/edx/testing.txt
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 1dcd7e108afb3d51d83f4297cbb464637f3297d0..e8bee9b447afea5a2d3914f524f50e2390c63441 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -148,6 +148,7 @@ help-tokens==1.0.5        # via -r requirements/edx/base.txt
 html5lib==1.0.1           # via -r requirements/edx/base.txt, ora2
 httplib2==0.17.0          # via -r requirements/edx/base.txt, oauth2
 httpretty==0.9.7          # via -r requirements/edx/testing.in
+icalendar==4.0.4          # via -r requirements/edx/base.txt
 idna==2.9                 # via -r requirements/edx/base.txt, requests
 importlib-metadata==1.5.0  # via -r requirements/edx/base.txt, -r requirements/edx/coverage.txt, importlib-resources, inflect, path, pluggy, pytest, pytest-randomly, tox, virtualenv
 importlib-resources==1.3.1  # via virtualenv
@@ -238,14 +239,14 @@ pytest-metadata==1.8.0    # via pytest-json-report
 pytest-randomly==3.2.1    # via -r requirements/edx/testing.in
 pytest-xdist==1.31.0      # via -r requirements/edx/testing.in
 pytest==5.4.1             # via -r requirements/edx/testing.in, pytest-attrib, pytest-cov, pytest-django, pytest-forked, pytest-json-report, pytest-metadata, pytest-randomly, pytest-xdist
-python-dateutil==2.4.0    # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt, -r requirements/edx/coverage.txt, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, faker, freezegun, ora2, pandas, xblock
+python-dateutil==2.4.0    # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt, -r requirements/edx/coverage.txt, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, faker, freezegun, icalendar, ora2, pandas, xblock
 python-levenshtein==0.12.0  # via -r requirements/edx/base.txt
 python-memcached==1.59    # via -r requirements/edx/base.txt
 python-slugify==4.0.0     # via -r requirements/edx/base.txt, code-annotations, transifex-client
 python-swiftclient==3.9.0  # via -r requirements/edx/base.txt, ora2
 python3-openid==3.1.0 ; python_version >= "3"  # via -r requirements/edx/base.txt, social-auth-core
 python3-saml==1.5.0       # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt
-pytz==2019.3              # via -r requirements/edx/base.txt, -r requirements/edx/coverage.txt, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, ora2, pandas, xblock
+pytz==2019.3              # via -r requirements/edx/base.txt, -r requirements/edx/coverage.txt, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, icalendar, ora2, pandas, xblock
 pyuca==1.2                # via -r requirements/edx/base.txt
 pyyaml==5.3               # via -r requirements/edx/base.txt, code-annotations, edx-django-release-util, edx-i18n-tools, xblock
 radon==4.1.0              # via -r requirements/edx/testing.in