From 2a69f0b7f3664d6b33f98a09b38c1471187ac574 Mon Sep 17 00:00:00 2001 From: Reimirno Date: Sat, 18 Nov 2023 16:06:15 -0800 Subject: [PATCH 01/10] scaffolds smtp and templating --- .env.sample | 6 +- README.md | 7 +- config.py | 5 +- requirements.txt | 1 - server/__init__.py | 3 + server/services/email/__init__.py | 151 +++++++++++------- server/services/email/smtp.py | 32 ++++ server/services/email/templates/__init__.py | 41 +++++ .../templates/assignment_inform_email.html | 33 ++++ .../templates/assignment_inform_email.json | 5 + server/typings/enum.py | 4 + 11 files changed, 222 insertions(+), 66 deletions(-) create mode 100644 server/services/email/smtp.py create mode 100644 server/services/email/templates/__init__.py create mode 100644 server/services/email/templates/assignment_inform_email.html create mode 100644 server/services/email/templates/assignment_inform_email.json diff --git a/.env.sample b/.env.sample index 7ee4e66..9ebf556 100644 --- a/.env.sample +++ b/.env.sample @@ -10,4 +10,8 @@ CANVAS_SERVER_URL= CANVAS_CLIENT_ID= CANVAS_CLIENT_SECRET= CANVAS_COURSE_ID= -SENDGRID_API_KEY= + +EMAIL_SERVER= +EMAIL_PORT= +EMAIL_USE_TLS= +EMAIL_USERNAME= diff --git a/README.md b/README.md index 2092311..921ccc4 100644 --- a/README.md +++ b/README.md @@ -231,8 +231,11 @@ CANVAS_SERVER_URL= CANVAS_CLIENT_ID= CANVAS_CLIENT_SECRET= -# sendgrid api key, get it from sendgrid dashboard -SENDGRID_API_KEY= +# email setup, use any smtp server +EMAIL_SERVER= +EMAIL_PORT= +EMAIL_USE_TLS= +EMAIL_USERNAME= # misc DOMAIN=localhost:5000 diff --git a/config.py b/config.py index 8316370..7f158c0 100644 --- a/config.py +++ b/config.py @@ -35,7 +35,10 @@ def getenv(key, default: Optional[str] = None): SEND_EMAIL = getenv('SEND_EMAIL', 'off').lower() # Email setup. Domain environment is for link in email. - SENDGRID_API_KEY = getenv('SENDGRID_API_KEY', "placeholder") + EMAIL_SERVER = getenv('EMAIL_SERVER') + EMAIL_PORT = getenv('EMAIL_PORT') + EMAIL_USERNAME = getenv('EMAIL_USERNAME') + EMAIL_PASSWORD = getenv('EMAIL_PASSWORD') PHOTO_DIRECTORY = getenv('PHOTO_DIRECTORY', "placeholder") diff --git a/requirements.txt b/requirements.txt index 0237e53..3e5059c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,6 @@ Flask_WTF==1.1.1 google_api_python_client==2.101.0 natsort==8.4.0 oauth2client==4.1.3 -sendgrid==6.10.0 SQLAlchemy==1.4.42 Werkzeug==0.16.1 WTForms==3.0.1 diff --git a/server/__init__.py b/server/__init__.py index fe7f13e..e5fbd79 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -83,3 +83,6 @@ def handle_invalid_access_token(e): # registers flask cli commands import cli # noqa + +from server.services.email import test_send_email # noqa +test_send_email() # noqa diff --git a/server/services/email/__init__.py b/server/services/email/__init__.py index 9fb8321..433e97b 100644 --- a/server/services/email/__init__.py +++ b/server/services/email/__init__.py @@ -1,73 +1,102 @@ - -import sendgrid - from server import app from server.models import db, Room, Seat, SeatAssignment +from server.services.email.smtp import SMTPConfig, send_email +import server.services.email.templates as templates +from server.typings.enum import EmailTemplate + +_email_config = SMTPConfig( + app.config.get('EMAIL_SERVER'), + app.config.get('EMAIL_PORT'), + app.config.get('EMAIL_USERNAME'), + app.config.get('EMAIL_PASSWORD') +) + + +def test_send_email(): + test_email = templates.get_email(EmailTemplate.ASSIGNMENT_INFORM_EMAIL, + {"EXAM": "test exam"}, + {"NAME": "test name", + "COURSE": "test course", + "EXAM": "test exam", + "ROOM": "test room", + "SEAT": "test seat", + "URL": "test/url", + "ADDITIONAL_INFO": "test additional text", + "SIGNATURE": "test signature"}) + send_email(smtp=_email_config, + from_addr="johnsonlongyu@gmail.com", + to_addr="long_yu@berkeley.edu", + subject=test_email.subject, + body=test_email.body, + body_html=test_email.body if test_email.body_html else None) def email_students(exam, form): - """Emails students in batches of 900""" - sg = sendgrid.SendGridAPIClient(api_key=app.config['SENDGRID_API_KEY']) - test = form.test_email.data - while True: - limit = 1 if test else 900 - assignments = SeatAssignment.query.join(SeatAssignment.seat).join(Seat.room).filter( - Room.exam_id == exam.id, - not SeatAssignment.emailed - ).limit(limit).all() - if not assignments: - break + pass + +# def email_students(exam, form): +# """Emails students in batches of 900""" +# sg = sendgrid.SendGridAPICslient(api_key=app.config['SENDGRID_API_KEY']) +# test = form.test_email.data +# while True: +# limit = 1 if test else 900 +# assignments = SeatAssignment.query.join(SeatAssignment.seat).join(Seat.room).filter( +# Room.exam_id == exam.id, +# not SeatAssignment.emailed +# ).limit(limit).all() +# if not assignments: +# break - data = { - 'personalizations': [ - { - 'to': [ - { - 'email': test if test else assignment.student.email, - } - ], - 'substitutions': { - '-name-': assignment.student.first_name, - '-room-': assignment.seat.room.display_name, - '-seat-': assignment.seat.name, - '-seatid-': str(assignment.seat.id), - }, - } - for assignment in assignments - ], - 'from': { - 'email': form.from_email.data, - 'name': form.from_name.data, - }, - 'subject': form.subject.data, - 'content': [ - { - 'type': 'text/plain', - 'value': ''' -Hi -name-, +# data = { +# 'personalizations': [ +# { +# 'to': [ +# { +# 'email': test if test else assignment.student.email, +# } +# ], +# 'substitutions': { +# '-name-': assignment.student.first_name, +# '-room-': assignment.seat.room.display_name, +# '-seat-': assignment.seat.name, +# '-seatid-': str(assignment.seat.id), +# }, +# } +# for assignment in assignments +# ], +# 'from': { +# 'email': form.from_email.data, +# 'name': form.from_name.data, +# }, +# 'subject': form.subject.data, +# 'content': [ +# { +# 'type': 'text/plain', +# 'value': ''' +# Hi -name-, -Here's your assigned seat for {}: +# Here's your assigned seat for {}: -Room: -room- +# Room: -room- -Seat: -seat- +# Seat: -seat- -You can view this seat's position on the seating chart at: -{}/seat/-seatid-/ +# You can view this seat's position on the seating chart at: +# {}/seat/-seatid-/ -{} -'''.format(exam.display_name, app.config['DOMAIN'], form.additional_text.data) - }, - ], - } +# {} +# '''.format(exam.display_name, app.config['DOMAIN'], form.additional_text.data) +# }, +# ], +# } - response = sg.client.mail.send.post(request_body=data) - if response.status_code < 200 or response.status_code >= 400: - raise Exception('Could not send mail. Status: {}. Body: {}'.format( - response.status_code, response.body - )) - if test: - return - for assignment in assignments: - assignment.emailed = True - db.session.commit() +# response = sg.client.mail.send.post(request_body=data) +# if response.status_code < 200 or response.status_code >= 400: +# raise Exception('Could not send mail. Status: {}. Body: {}'.format( +# response.status_code, response.body +# )) +# if test: +# return +# for assignment in assignments: +# assignment.emailed = True +# db.session.commit() diff --git a/server/services/email/smtp.py b/server/services/email/smtp.py new file mode 100644 index 0000000..f02a081 --- /dev/null +++ b/server/services/email/smtp.py @@ -0,0 +1,32 @@ +import smtplib +from email.message import EmailMessage + + +class SMTPConfig: + def __init__(self, smtp_server, smtp_port, username, password): + self.smtp_server = smtp_server + self.smtp_port = smtp_port + self.username = username + self.password = password + + +def send_email(*, smtp: SMTPConfig, from_addr, to_addr, subject, body, body_html=None, use_tls=True): + msg = EmailMessage() + msg['From'] = from_addr + msg['To'] = to_addr + msg['Subject'] = subject + msg.set_content(body) + + if body_html: + msg.add_alternative(body_html, subtype='html') + + try: + with smtplib.SMTP(smtp.smtp_server, smtp.smtp_port) as server: + if use_tls: + server.starttls() + server.login(smtp.username, smtp.password) + server.send_message(msg) + return True + except Exception as e: + print(e) + return False diff --git a/server/services/email/templates/__init__.py b/server/services/email/templates/__init__.py new file mode 100644 index 0000000..788e1e1 --- /dev/null +++ b/server/services/email/templates/__init__.py @@ -0,0 +1,41 @@ +from server.typings.enum import EmailTemplate +import os +import json +import re + + +class EmailContent: + def __init__(self, subject: str, body: str, body_html: bool): + self.subject = subject + self.body = body + self.body_html = body_html + + def __repr__(self) -> str: + return f'EmailContent(subject={self.subject}, body={self.body}, body_type={self.body_html})' + + +def get_email( + template: EmailTemplate, + subject_substitutions: dict[str, str], + body_substitutions: dict[str, str] +) -> EmailContent: + # template.value is the file name for email metadata (json) + # let us read it that first. The file sits in the same path as this file. + email_metadata = None + with open(os.path.join(os.path.dirname(__file__), template.value + ".json")) as f: + email_metadata = json.load(f) + subject = _make_substitutions(email_metadata['subject'], subject_substitutions) + body_html = email_metadata['body_html'] + body_path = os.path.join(os.path.dirname(__file__), email_metadata['body_path']) + body = None + with open(body_path) as f: + body = _make_substitutions(f.read(), body_substitutions) + content = EmailContent(subject, body, re.match('[Tt]rue', body_html) is not None) + print(content) + return content + + +def _make_substitutions(text: str, substitutions: dict[str, str]) -> str: + for key, value in substitutions.items(): + text = re.sub(r'\{\{\s*' + key + r'\s*\}\}', value, text) + return text diff --git a/server/services/email/templates/assignment_inform_email.html b/server/services/email/templates/assignment_inform_email.html new file mode 100644 index 0000000..23ba9cb --- /dev/null +++ b/server/services/email/templates/assignment_inform_email.html @@ -0,0 +1,33 @@ + + + + Seating Assignment for {{COURSE}} {{EXAM}} + + +

Dear {{NAME}},

+ +

Here's your assigned seat for the upcoming exam:

+ + + +

You can view this seat's position on the seating chart at:

+ View Seating Chart + +

+ If you have any questions or require further assistance, please do not + hesitate to contact the relevant course staff. +

+ +

Good luck on your exam!

+ +

{{ADDITIONAL_INFO}}

+ +

Best,

+

{{SIGNATURE}}

+ + diff --git a/server/services/email/templates/assignment_inform_email.json b/server/services/email/templates/assignment_inform_email.json new file mode 100644 index 0000000..d9e001b --- /dev/null +++ b/server/services/email/templates/assignment_inform_email.json @@ -0,0 +1,5 @@ +{ + "subject": "Your Exam Seating Assignment for {{EXAM}}", + "body_path": "assignment_inform_email.html", + "body_html": "true" +} diff --git a/server/typings/enum.py b/server/typings/enum.py index 309cae0..bc658a8 100644 --- a/server/typings/enum.py +++ b/server/typings/enum.py @@ -12,3 +12,7 @@ class EmailSendingConfig(Enum): OFF = 'off' # does not send emails TEST = 'test' # sends all emails to test email address ON = 'on' # sends emails to real email addresses + + +class EmailTemplate(Enum): + ASSIGNMENT_INFORM_EMAIL = 'assignment_inform_email' From e30cb907fed0d9810156760e8b53d116ab46803b Mon Sep 17 00:00:00 2001 From: Reimirno Date: Sun, 19 Nov 2023 14:18:33 -0800 Subject: [PATCH 02/10] feat: email testing --- server/__init__.py | 3 -- server/services/email/__init__.py | 20 +---------- server/services/email/smtp.py | 14 ++++---- server/services/email/templates/__init__.py | 1 - tests/unit/test_email.py | 39 +++++++++++++++++++++ 5 files changed, 47 insertions(+), 30 deletions(-) create mode 100644 tests/unit/test_email.py diff --git a/server/__init__.py b/server/__init__.py index e5fbd79..fe7f13e 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -83,6 +83,3 @@ def handle_invalid_access_token(e): # registers flask cli commands import cli # noqa - -from server.services.email import test_send_email # noqa -test_send_email() # noqa diff --git a/server/services/email/__init__.py b/server/services/email/__init__.py index 433e97b..e926149 100644 --- a/server/services/email/__init__.py +++ b/server/services/email/__init__.py @@ -12,28 +12,10 @@ ) -def test_send_email(): - test_email = templates.get_email(EmailTemplate.ASSIGNMENT_INFORM_EMAIL, - {"EXAM": "test exam"}, - {"NAME": "test name", - "COURSE": "test course", - "EXAM": "test exam", - "ROOM": "test room", - "SEAT": "test seat", - "URL": "test/url", - "ADDITIONAL_INFO": "test additional text", - "SIGNATURE": "test signature"}) - send_email(smtp=_email_config, - from_addr="johnsonlongyu@gmail.com", - to_addr="long_yu@berkeley.edu", - subject=test_email.subject, - body=test_email.body, - body_html=test_email.body if test_email.body_html else None) - - def email_students(exam, form): pass + # def email_students(exam, form): # """Emails students in batches of 900""" # sg = sendgrid.SendGridAPICslient(api_key=app.config['SENDGRID_API_KEY']) diff --git a/server/services/email/smtp.py b/server/services/email/smtp.py index f02a081..d5f248d 100644 --- a/server/services/email/smtp.py +++ b/server/services/email/smtp.py @@ -1,4 +1,4 @@ -import smtplib +from smtplib import SMTP from email.message import EmailMessage @@ -10,7 +10,7 @@ def __init__(self, smtp_server, smtp_port, username, password): self.password = password -def send_email(*, smtp: SMTPConfig, from_addr, to_addr, subject, body, body_html=None, use_tls=True): +def send_email(*, smtp: SMTPConfig, from_addr, to_addr, subject, body, body_html=None): msg = EmailMessage() msg['From'] = from_addr msg['To'] = to_addr @@ -21,11 +21,11 @@ def send_email(*, smtp: SMTPConfig, from_addr, to_addr, subject, body, body_html msg.add_alternative(body_html, subtype='html') try: - with smtplib.SMTP(smtp.smtp_server, smtp.smtp_port) as server: - if use_tls: - server.starttls() - server.login(smtp.username, smtp.password) - server.send_message(msg) + server = SMTP(smtp.smtp_server, smtp.smtp_port) + server.starttls() + server.login(smtp.username, smtp.password) + server.send_message(msg) + server.quit() return True except Exception as e: print(e) diff --git a/server/services/email/templates/__init__.py b/server/services/email/templates/__init__.py index 788e1e1..5619507 100644 --- a/server/services/email/templates/__init__.py +++ b/server/services/email/templates/__init__.py @@ -31,7 +31,6 @@ def get_email( with open(body_path) as f: body = _make_substitutions(f.read(), body_substitutions) content = EmailContent(subject, body, re.match('[Tt]rue', body_html) is not None) - print(content) return content diff --git a/tests/unit/test_email.py b/tests/unit/test_email.py new file mode 100644 index 0000000..341ac21 --- /dev/null +++ b/tests/unit/test_email.py @@ -0,0 +1,39 @@ +from unittest.mock import patch +import server.services.email.templates as templates +from server.services.email import send_email, _email_config +from server.typings.enum import EmailTemplate + + +@patch('server.services.email.smtp.SMTP') +def test_send_email(mock_smtp): + test_email = \ + templates.get_email(EmailTemplate.ASSIGNMENT_INFORM_EMAIL, + {"EXAM": "test exam"}, + {"NAME": "test name", + "COURSE": "test course", + "EXAM": "test exam", + "ROOM": "test room", + "SEAT": "test seat", + "URL": "test/url", + "ADDITIONAL_INFO": "test additional text", + "SIGNATURE": "test signature"}) + + send_email(smtp=_email_config, + from_addr="from@xyz.com", + to_addr="to@xyz.com", + subject=test_email.subject, + body=test_email.body, + body_html=test_email.body if test_email.body_html else None) + + # basic checks of the use of smtp server + mock_smtp.assert_called_with(_email_config.smtp_server, _email_config.smtp_port) + mock_smtp.return_value.starttls.assert_called_once() + mock_smtp.return_value.login.assert_called_once_with(_email_config.username, _email_config.password) + mock_smtp.return_value.send_message.assert_called_once() + mock_smtp.return_value.quit.assert_called_once() + + # check the email content + msg = mock_smtp.return_value.send_message.call_args[0][0] + assert msg['From'] == "from@xyz.com" + assert msg['To'] == "to@xyz.com" + assert msg['Subject'] == test_email.subject From 35b5389460ff6120432a8e00383e0f4d8421dfaf Mon Sep 17 00:00:00 2001 From: Reimirno Date: Sun, 19 Nov 2023 15:31:10 -0800 Subject: [PATCH 03/10] feat: implement email_students --- server/models.py | 13 +++++++++ server/services/email/__init__.py | 48 +++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/server/models.py b/server/models.py index 9bf89d0..baac63f 100644 --- a/server/models.py +++ b/server/models.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.associationproxy import association_proxy from server import app +from server.views import assign db = SQLAlchemy(app=app) @@ -84,6 +85,18 @@ def unassigned_seats(self): def unassigned_students(self): return [student for student in self.students if student.assignment == None] # noqa + def get_assignments(self, emailed=None, limit=None, offset=None): + query = SeatAssignment.query.join(SeatAssignment.seat).join(Seat.room).filter( + Room.exam_id == self.id, + ) + if emailed is not None: + query = query.filter(SeatAssignment.emailed == emailed) + if limit is not None: + query = query.limit(limit) + if offset is not None: + query = query.offset(offset) + return query.all() + def __repr__(self): return ''.format(self.name) diff --git a/server/services/email/__init__.py b/server/services/email/__init__.py index e926149..8cdac16 100644 --- a/server/services/email/__init__.py +++ b/server/services/email/__init__.py @@ -1,8 +1,10 @@ from server import app -from server.models import db, Room, Seat, SeatAssignment +from server.models import db, Exam, SeatAssignment, Offering from server.services.email.smtp import SMTPConfig, send_email import server.services.email.templates as templates from server.typings.enum import EmailTemplate +from flask import url_for +import os _email_config = SMTPConfig( app.config.get('EMAIL_SERVER'), @@ -12,8 +14,48 @@ ) -def email_students(exam, form): - pass +def email_students(exam: Exam, form): + ASSIGNMENT_PER_PAGE = 500 + page_number = 1 + + while True: + assignments = exam.get_assignments( + emailed=False, + limit=ASSIGNMENT_PER_PAGE, + offset=(page_number - 1) * ASSIGNMENT_PER_PAGE + ) + if not assignments: + break + page_number += 1 + + for assignment in assignments: + if _email_single_assignment(exam.offering, exam, assignment, form): + assignment.emailed = True + + db.session.commit() + + +def _email_single_assignment(offering: Offering, exam: Exam, assignment: SeatAssignment, form) -> bool: + seat_path = url_for('student_single_seat', seat_id=assignment.seat.id) + seat_absolute_path = os.path.join(app.config['DOMAIN'], seat_path) + student_email = \ + templates.get_email(EmailTemplate.ASSIGNMENT_INFORM_EMAIL, + {"EXAM": exam.display_name}, + {"NAME": assignment.student.first_name, + "COURSE": offering.name, + "EXAM": exam.display_name, + "ROOM": assignment.seat.room.display_name, + "SEAT": assignment.seat.name, + "URL": seat_absolute_path, + "ADDITIONAL_INFO": form.additional_text.data, + "SIGNATURE": form.from_name.data}) + + return send_email(smtp=_email_config, + from_addr=form.from_email.data, + to_addr=assignment.student.email, + subject=student_email.subject, + body=student_email.body, + body_html=student_email.body if student_email.body_html else None) # def email_students(exam, form): From 541230b28738bb1aa266620d92a2509b38324746 Mon Sep 17 00:00:00 2001 From: Reimirno Date: Sun, 19 Nov 2023 16:27:00 -0800 Subject: [PATCH 04/10] feat: setup mock smtp server for testing --- config.py | 10 +-- requirements-dev.txt | 1 + server/models.py | 1 - server/services/email/smtp.py | 8 +- tests/unit/test_email.py | 151 ++++++++++++++++++++++++++++------ 5 files changed, 136 insertions(+), 35 deletions(-) diff --git a/config.py b/config.py index 7f158c0..07abaf2 100644 --- a/config.py +++ b/config.py @@ -35,12 +35,12 @@ def getenv(key, default: Optional[str] = None): SEND_EMAIL = getenv('SEND_EMAIL', 'off').lower() # Email setup. Domain environment is for link in email. - EMAIL_SERVER = getenv('EMAIL_SERVER') - EMAIL_PORT = getenv('EMAIL_PORT') - EMAIL_USERNAME = getenv('EMAIL_USERNAME') - EMAIL_PASSWORD = getenv('EMAIL_PASSWORD') + EMAIL_SERVER = getenv('EMAIL_SERVER', "unset") + EMAIL_PORT = getenv('EMAIL_PORT', "unset") + EMAIL_USERNAME = getenv('EMAIL_USERNAME', "unset") + EMAIL_PASSWORD = getenv('EMAIL_PASSWORD', "unset") - PHOTO_DIRECTORY = getenv('PHOTO_DIRECTORY', "placeholder") + PHOTO_DIRECTORY = getenv('PHOTO_DIRECTORY', "unset") class ProductionConfig(ConfigBase): diff --git a/requirements-dev.txt b/requirements-dev.txt index 21e346f..f0de3d7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,3 +6,4 @@ safety==1.10.3 pip-audit==2.6.1 selenium==4.14.0 responses==0.23.3 +aiosmtpd==1.4.4.post2 diff --git a/server/models.py b/server/models.py index baac63f..dd1c514 100644 --- a/server/models.py +++ b/server/models.py @@ -11,7 +11,6 @@ from sqlalchemy.ext.associationproxy import association_proxy from server import app -from server.views import assign db = SQLAlchemy(app=app) diff --git a/server/services/email/smtp.py b/server/services/email/smtp.py index d5f248d..6f6b981 100644 --- a/server/services/email/smtp.py +++ b/server/services/email/smtp.py @@ -22,11 +22,13 @@ def send_email(*, smtp: SMTPConfig, from_addr, to_addr, subject, body, body_html try: server = SMTP(smtp.smtp_server, smtp.smtp_port) - server.starttls() - server.login(smtp.username, smtp.password) + if server.has_extn('STARTTLS'): + server.starttls() + if server.has_extn('AUTH'): + server.login(smtp.username, smtp.password) server.send_message(msg) server.quit() return True except Exception as e: - print(e) + print(f"Error sending email: {e}\n Config: \n{smtp}") return False diff --git a/tests/unit/test_email.py b/tests/unit/test_email.py index 341ac21..df9beb7 100644 --- a/tests/unit/test_email.py +++ b/tests/unit/test_email.py @@ -1,39 +1,138 @@ +import pytest from unittest.mock import patch import server.services.email.templates as templates -from server.services.email import send_email, _email_config +from server.services.email import send_email, _email_config, SMTPConfig from server.typings.enum import EmailTemplate +from email.message import Message + +TEST_FROM_EMAIL = 'sender@example.com' +TEST_TO_EMAIL = 'recipient@example.com' +TEST_SUBJECT = 'Test Subject' +TEST_BODY = 'Test Body' +TEST_BODY_HTML = '

Test Body

' + + +def get_content(msg: Message, type='text/html'): + if msg.is_multipart(): + for part in msg.get_payload(): + if part.get_content_type() == type: + return part.get_payload(decode=True).decode(part.get_content_charset()) + else: + if msg.get_content_type() == type: + return msg.get_payload(decode=True).decode(msg.get_content_charset()) + return None @patch('server.services.email.smtp.SMTP') -def test_send_email(mock_smtp): - test_email = \ - templates.get_email(EmailTemplate.ASSIGNMENT_INFORM_EMAIL, - {"EXAM": "test exam"}, - {"NAME": "test name", - "COURSE": "test course", - "EXAM": "test exam", - "ROOM": "test room", - "SEAT": "test seat", - "URL": "test/url", - "ADDITIONAL_INFO": "test additional text", - "SIGNATURE": "test signature"}) - - send_email(smtp=_email_config, - from_addr="from@xyz.com", - to_addr="to@xyz.com", - subject=test_email.subject, - body=test_email.body, - body_html=test_email.body if test_email.body_html else None) - - # basic checks of the use of smtp server +def test_send_plain_text_email(mock_smtp): + + success = send_email(smtp=_email_config, + from_addr=TEST_FROM_EMAIL, + to_addr=TEST_TO_EMAIL, + subject=TEST_SUBJECT, + body=TEST_BODY) + + assert success + + # check the use of smtp server mock_smtp.assert_called_with(_email_config.smtp_server, _email_config.smtp_port) mock_smtp.return_value.starttls.assert_called_once() mock_smtp.return_value.login.assert_called_once_with(_email_config.username, _email_config.password) mock_smtp.return_value.send_message.assert_called_once() mock_smtp.return_value.quit.assert_called_once() - # check the email content + # check email meta + msg = mock_smtp.return_value.send_message.call_args[0][0] + assert msg['From'] == TEST_FROM_EMAIL + assert msg['To'] == TEST_TO_EMAIL + assert msg['Subject'] == TEST_SUBJECT + + # check plain text content + assert TEST_BODY in msg.get_payload() + + +@patch('server.services.email.smtp.SMTP') +def test_send_html_email(mock_smtp): + + success = send_email(smtp=_email_config, + from_addr=TEST_FROM_EMAIL, + to_addr=TEST_TO_EMAIL, + subject=TEST_SUBJECT, + body=TEST_BODY, + body_html=TEST_BODY_HTML) + + assert success + msg = mock_smtp.return_value.send_message.call_args[0][0] - assert msg['From'] == "from@xyz.com" - assert msg['To'] == "to@xyz.com" - assert msg['Subject'] == test_email.subject + html = get_content(msg, 'text/html') + assert html is not None + assert TEST_BODY_HTML in html + + +import threading # noqa +from aiosmtpd.controller import Controller # noqa +from aiosmtpd.handlers import Message as MessageHandler # noqa +from email import message_from_string # noqa + + +class CustomMessageHandler(MessageHandler): + received_message = None + + def handle_message(self, message): + CustomMessageHandler.received_message = message_from_string(message.as_string()) + + +@pytest.fixture() +def smtp_server(): + controller = Controller(CustomMessageHandler(), hostname='localhost', port=1025) + thread = threading.Thread(target=controller.start) + thread.start() + + yield controller + + controller.stop() + thread.join() + + +def test_send_plain_text_email_with_mock_smtp_server(smtp_server): + smtp_config = SMTPConfig(smtp_server.hostname, smtp_server.port, "user", "pass") + + success = send_email( + smtp=smtp_config, + from_addr=TEST_FROM_EMAIL, + to_addr=TEST_TO_EMAIL, + subject=TEST_SUBJECT, + body=TEST_BODY) + + assert success + + msg = CustomMessageHandler.received_message + CustomMessageHandler.received_message = None + + assert msg is not None + assert msg['From'] == TEST_FROM_EMAIL + assert msg['To'] == TEST_TO_EMAIL + assert msg['Subject'] == TEST_SUBJECT + assert TEST_BODY in msg.get_payload() + + +def test_send_html_email_with_mock_smtp_server(smtp_server): + smtp_config = SMTPConfig(smtp_server.hostname, smtp_server.port, "user", "pass") + + success = send_email( + smtp=smtp_config, + from_addr=TEST_FROM_EMAIL, + to_addr=TEST_TO_EMAIL, + subject=TEST_SUBJECT, + body=TEST_BODY, + body_html=TEST_BODY_HTML) + + assert success + + msg = CustomMessageHandler.received_message + CustomMessageHandler.received_message = None + + # check html content + html = get_content(msg, 'text/html') + assert html is not None + assert TEST_BODY_HTML in html From e546fab2b116fea8040f189c8ae4c6874c13a9de Mon Sep 17 00:00:00 2001 From: Reimirno Date: Sun, 19 Nov 2023 17:58:39 -0800 Subject: [PATCH 05/10] chore: add comments --- tests/unit/test_email.py | 45 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_email.py b/tests/unit/test_email.py index df9beb7..784e5ae 100644 --- a/tests/unit/test_email.py +++ b/tests/unit/test_email.py @@ -12,7 +12,10 @@ TEST_BODY_HTML = '

Test Body

' -def get_content(msg: Message, type='text/html'): +def _get_content(msg: Message, type='text/html'): + """ + returns the content of the email body with the given type + """ if msg.is_multipart(): for part in msg.get_payload(): if part.get_content_type() == type: @@ -25,6 +28,9 @@ def get_content(msg: Message, type='text/html'): @patch('server.services.email.smtp.SMTP') def test_send_plain_text_email(mock_smtp): + """ + Stubs out the SMTP server and checks that plain text email is sent correctly + """ success = send_email(smtp=_email_config, from_addr=TEST_FROM_EMAIL, @@ -53,6 +59,9 @@ def test_send_plain_text_email(mock_smtp): @patch('server.services.email.smtp.SMTP') def test_send_html_email(mock_smtp): + """ + Stubs out the SMTP server and checks that html email is sent correctly + """ success = send_email(smtp=_email_config, from_addr=TEST_FROM_EMAIL, @@ -64,7 +73,7 @@ def test_send_html_email(mock_smtp): assert success msg = mock_smtp.return_value.send_message.call_args[0][0] - html = get_content(msg, 'text/html') + html = _get_content(msg, 'text/html') assert html is not None assert TEST_BODY_HTML in html @@ -84,7 +93,9 @@ def handle_message(self, message): @pytest.fixture() def smtp_server(): - controller = Controller(CustomMessageHandler(), hostname='localhost', port=1025) + controller = Controller(CustomMessageHandler(), hostname='127.0.0.1', port=1025) + # has to use 127.0.0.1 instead of localhost so that the test can run on Github Actions + # otherwise, the test does not seem to be able to find the smtp server thread = threading.Thread(target=controller.start) thread.start() @@ -95,6 +106,9 @@ def smtp_server(): def test_send_plain_text_email_with_mock_smtp_server(smtp_server): + """ + Use a local fake smtp server to test that plain text email is sent correctly + """ smtp_config = SMTPConfig(smtp_server.hostname, smtp_server.port, "user", "pass") success = send_email( @@ -117,6 +131,9 @@ def test_send_plain_text_email_with_mock_smtp_server(smtp_server): def test_send_html_email_with_mock_smtp_server(smtp_server): + """ + Use a local fake smtp server to test that html email is sent correctly + """ smtp_config = SMTPConfig(smtp_server.hostname, smtp_server.port, "user", "pass") success = send_email( @@ -133,6 +150,26 @@ def test_send_html_email_with_mock_smtp_server(smtp_server): CustomMessageHandler.received_message = None # check html content - html = get_content(msg, 'text/html') + html = _get_content(msg, 'text/html') assert html is not None assert TEST_BODY_HTML in html + +# def test_send_test_email(smtp_server): +# test_email = \ +# templates.get_email(EmailTemplate.ASSIGNMENT_INFORM_EMAIL, +# {"EXAM": "test exam"}, +# {"NAME": "test name", +# "COURSE": "test course", +# "EXAM": "test exam", +# "ROOM": "test room", +# "SEAT": "test seat", +# "URL": "test/url", +# "ADDITIONAL_INFO": "test additional text", +# "SIGNATURE": "test signature"}) + +# send_email(smtp=_email_config, +# from_addr=TEST_FROM_EMAIL, +# to_addr=TEST_TO_EMAIL, +# subject=test_email.subject, +# body=test_email.body, +# body_html=test_email.body if test_email.body_html else None) From 5723d527496eb1f9545fa0bd602848aa02fb26fe Mon Sep 17 00:00:00 2001 From: Reimirno Date: Sun, 19 Nov 2023 18:07:49 -0800 Subject: [PATCH 06/10] feat: add seating email test --- server/services/email/smtp.py | 3 ++ tests/unit/test_email.py | 67 +++++++++++++++++++++-------------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/server/services/email/smtp.py b/server/services/email/smtp.py index 6f6b981..8a23b80 100644 --- a/server/services/email/smtp.py +++ b/server/services/email/smtp.py @@ -9,6 +9,9 @@ def __init__(self, smtp_server, smtp_port, username, password): self.username = username self.password = password + def __repr__(self) -> str: + return f'SMTPConfig(smtp_server={self.smtp_server}, smtp_port={self.smtp_port}, username={self.username}, password={self.password})' # noqa + def send_email(*, smtp: SMTPConfig, from_addr, to_addr, subject, body, body_html=None): msg = EmailMessage() diff --git a/tests/unit/test_email.py b/tests/unit/test_email.py index 784e5ae..af043e1 100644 --- a/tests/unit/test_email.py +++ b/tests/unit/test_email.py @@ -83,6 +83,8 @@ def test_send_html_email(mock_smtp): from aiosmtpd.handlers import Message as MessageHandler # noqa from email import message_from_string # noqa +_fake_email_config = SMTPConfig('127.0.0.1', 1025, 'user', 'pass') + class CustomMessageHandler(MessageHandler): received_message = None @@ -93,7 +95,9 @@ def handle_message(self, message): @pytest.fixture() def smtp_server(): - controller = Controller(CustomMessageHandler(), hostname='127.0.0.1', port=1025) + controller = Controller(CustomMessageHandler(), + hostname=_fake_email_config.smtp_server, + port=_fake_email_config.smtp_port) # has to use 127.0.0.1 instead of localhost so that the test can run on Github Actions # otherwise, the test does not seem to be able to find the smtp server thread = threading.Thread(target=controller.start) @@ -101,6 +105,7 @@ def smtp_server(): yield controller + CustomMessageHandler.received_message = None controller.stop() thread.join() @@ -109,10 +114,9 @@ def test_send_plain_text_email_with_mock_smtp_server(smtp_server): """ Use a local fake smtp server to test that plain text email is sent correctly """ - smtp_config = SMTPConfig(smtp_server.hostname, smtp_server.port, "user", "pass") success = send_email( - smtp=smtp_config, + smtp=_fake_email_config, from_addr=TEST_FROM_EMAIL, to_addr=TEST_TO_EMAIL, subject=TEST_SUBJECT, @@ -121,7 +125,6 @@ def test_send_plain_text_email_with_mock_smtp_server(smtp_server): assert success msg = CustomMessageHandler.received_message - CustomMessageHandler.received_message = None assert msg is not None assert msg['From'] == TEST_FROM_EMAIL @@ -134,10 +137,9 @@ def test_send_html_email_with_mock_smtp_server(smtp_server): """ Use a local fake smtp server to test that html email is sent correctly """ - smtp_config = SMTPConfig(smtp_server.hostname, smtp_server.port, "user", "pass") success = send_email( - smtp=smtp_config, + smtp=_fake_email_config, from_addr=TEST_FROM_EMAIL, to_addr=TEST_TO_EMAIL, subject=TEST_SUBJECT, @@ -147,29 +149,42 @@ def test_send_html_email_with_mock_smtp_server(smtp_server): assert success msg = CustomMessageHandler.received_message - CustomMessageHandler.received_message = None # check html content html = _get_content(msg, 'text/html') assert html is not None assert TEST_BODY_HTML in html -# def test_send_test_email(smtp_server): -# test_email = \ -# templates.get_email(EmailTemplate.ASSIGNMENT_INFORM_EMAIL, -# {"EXAM": "test exam"}, -# {"NAME": "test name", -# "COURSE": "test course", -# "EXAM": "test exam", -# "ROOM": "test room", -# "SEAT": "test seat", -# "URL": "test/url", -# "ADDITIONAL_INFO": "test additional text", -# "SIGNATURE": "test signature"}) - -# send_email(smtp=_email_config, -# from_addr=TEST_FROM_EMAIL, -# to_addr=TEST_TO_EMAIL, -# subject=test_email.subject, -# body=test_email.body, -# body_html=test_email.body if test_email.body_html else None) + +def test_send_seating_html_email_with_mock_smtp_server(smtp_server): + + test_seating_email = \ + templates.get_email(EmailTemplate.ASSIGNMENT_INFORM_EMAIL, + {"EXAM": "test exam"}, + {"NAME": "test name", + "COURSE": "test course", + "EXAM": "test exam", + "ROOM": "test room", + "SEAT": "test seat", + "URL": "test/url", + "ADDITIONAL_INFO": "test additional text", + "SIGNATURE": "test signature"}) + + success = send_email(smtp=_fake_email_config, + from_addr=TEST_FROM_EMAIL, + to_addr=TEST_TO_EMAIL, + subject=test_seating_email.subject, + body=test_seating_email.body, + body_html=test_seating_email.body if test_seating_email.body_html else None) + + assert success + + msg = CustomMessageHandler.received_message + + assert msg['From'] == TEST_FROM_EMAIL + assert msg['To'] == TEST_TO_EMAIL + assert msg['Subject'] == test_seating_email.subject + + html = _get_content(msg, 'text/html') + assert html is not None + assert test_seating_email.body in html From 9efca3d23ce2e7d2ce6c04caf33ac89377b8d25d Mon Sep 17 00:00:00 2001 From: Reimirno Date: Sun, 19 Nov 2023 18:50:20 -0800 Subject: [PATCH 07/10] adds more seeding data and redo email form --- README.md | 2 +- config.py | 2 +- server/forms.py | 8 +-- .../canvas/fake_data/fake_courses.json | 12 ++++ .../canvas/fake_data/fake_enrollments.json | 61 ++++++++++++++++++- .../services/canvas/fake_data/fake_users.json | 20 ++++++ server/services/email/__init__.py | 8 +-- server/services/email/smtp.py | 12 ++-- server/templates/email.html.j2 | 28 +++------ tests/unit/test_email.py | 18 +++--- 10 files changed, 127 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 921ccc4..1db428d 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ EMAIL_USE_TLS= EMAIL_USERNAME= # misc -DOMAIN=localhost:5000 +SERVER_BASE_URL=localhost:5000 LOCAL_TIMEZONE=US/Pacific ``` diff --git a/config.py b/config.py index 07abaf2..0e3e1ef 100644 --- a/config.py +++ b/config.py @@ -34,7 +34,7 @@ def getenv(key, default: Optional[str] = None): MOCK_CANVAS = getenv('MOCK_CANVAS', 'false').lower() == 'true' SEND_EMAIL = getenv('SEND_EMAIL', 'off').lower() - # Email setup. Domain environment is for link in email. + # Email setup. EMAIL_SERVER = getenv('EMAIL_SERVER', "unset") EMAIL_PORT = getenv('EMAIL_PORT', "unset") EMAIL_USERNAME = getenv('EMAIL_USERNAME', "unset") diff --git a/server/forms.py b/server/forms.py index a8bcf9e..33d19a5 100644 --- a/server/forms.py +++ b/server/forms.py @@ -83,11 +83,9 @@ class AssignForm(FlaskForm): class EmailForm(FlaskForm): - from_email = StringField('from_email', [Email()]) - from_name = StringField('from_name', [InputRequired()]) - subject = StringField('subject', [InputRequired()]) - test_email = StringField('test_email') - additional_text = TextAreaField('additional_text') + from_addr = StringField('from_addr', [Email(), InputRequired()]) + signature = StringField('signature', [InputRequired()]) + additional_info = TextAreaField('additional_info') submit = SubmitField('send') diff --git a/server/services/canvas/fake_data/fake_courses.json b/server/services/canvas/fake_data/fake_courses.json index 8824dde..f6080a7 100644 --- a/server/services/canvas/fake_data/fake_courses.json +++ b/server/services/canvas/fake_data/fake_courses.json @@ -10,5 +10,17 @@ "name": "Introduction to the Internet: Architecture and Protocols (Fall 2022)", "sis_course_id": "", "course_code": "COMPSCI 168" + }, + "3456789": { + "id": 3456789, + "name": "Introduction to Computer Security (Fall 2022)", + "sis_course_id": "", + "course_code": "COMPSCI 161" + }, + "4567890": { + "id": 4567890, + "name": "Computer Architecture (Fall 2022)", + "sis_course_id": "", + "course_code": "COMPSCI 150" } } diff --git a/server/services/canvas/fake_data/fake_enrollments.json b/server/services/canvas/fake_data/fake_enrollments.json index 53b1fa3..eb45451 100644 --- a/server/services/canvas/fake_data/fake_enrollments.json +++ b/server/services/canvas/fake_data/fake_enrollments.json @@ -5,7 +5,7 @@ { "type": "ta", "role": "TaEnrollment", "enrollment_state": "active" } ] }, - "2345678": { + "4567890": { "enrollments": [ { "type": "student", @@ -30,5 +30,64 @@ { "type": "ta", "role": "TaEnrollment", "enrollment_state": "active" } ] } + }, + "345678": { + "1234567": { + "enrollments": [ + { + "type": "student", + "role": "StudentEnrollment", + "enrollment_state": "active" + } + ] + }, + "2345678": { + "enrollments": [ + { + "type": "student", + "role": "StudentEnrollment", + "enrollment_state": "active" + } + ] + }, + "3456789": { + "enrollments": [ + { "type": "ta", "role": "TaEnrollment", "enrollment_state": "active" } + ] + } + }, + "456789": { + "1234567": { + "enrollments": [ + { + "type": "student", + "role": "StudentEnrollment", + "enrollment_state": "active" + } + ] + }, + "2345678": { + "enrollments": [ + { + "type": "student", + "role": "StudentEnrollment", + "enrollment_state": "active" + } + ] + }, + "3456789": { + "enrollments": [ + { + "type": "student", + "role": "StudentEnrollment", + "enrollment_state": "active" + } + ] + }, + "4567890": { + "enrollments": [ + { "type": "ta", "role": "TaEnrollment", "enrollment_state": "active" } + ] + } } } diff --git a/server/services/canvas/fake_data/fake_users.json b/server/services/canvas/fake_data/fake_users.json index 8f3f099..8c81505 100644 --- a/server/services/canvas/fake_data/fake_users.json +++ b/server/services/canvas/fake_data/fake_users.json @@ -18,5 +18,25 @@ "sis_user_id": "3033033334", "login_id": "1717118", "email": "jx@berkeley.edu" + }, + "345678": { + "id": 345678, + "name": "Sharon Lovera", + "global_id": "10000000000002", + "effective_locale": "en", + "short_name": "Sharon Lovera", + "sis_user_id": "3033033335", + "login_id": "1717119", + "email": "sharon@berkeley.edu" + }, + "456789": { + "id": 456789, + "name": "Lavender Angela", + "global_id": "10000000000003", + "effective_locale": "en", + "short_name": "Lavender Angela", + "sis_user_id": "3033033336", + "login_id": "1717120", + "email": "angelaz8@berkeley.edu" } } diff --git a/server/services/email/__init__.py b/server/services/email/__init__.py index 8cdac16..c205ff4 100644 --- a/server/services/email/__init__.py +++ b/server/services/email/__init__.py @@ -37,7 +37,7 @@ def email_students(exam: Exam, form): def _email_single_assignment(offering: Offering, exam: Exam, assignment: SeatAssignment, form) -> bool: seat_path = url_for('student_single_seat', seat_id=assignment.seat.id) - seat_absolute_path = os.path.join(app.config['DOMAIN'], seat_path) + seat_absolute_path = os.path.join(app.config.get('SERVER_BASE_URL'), seat_path) student_email = \ templates.get_email(EmailTemplate.ASSIGNMENT_INFORM_EMAIL, {"EXAM": exam.display_name}, @@ -47,11 +47,11 @@ def _email_single_assignment(offering: Offering, exam: Exam, assignment: SeatAss "ROOM": assignment.seat.room.display_name, "SEAT": assignment.seat.name, "URL": seat_absolute_path, - "ADDITIONAL_INFO": form.additional_text.data, - "SIGNATURE": form.from_name.data}) + "ADDITIONAL_INFO": form.additional_info.data, + "SIGNATURE": form.signature.data}) return send_email(smtp=_email_config, - from_addr=form.from_email.data, + from_addr=form.from_addr.data, to_addr=assignment.student.email, subject=student_email.subject, body=student_email.body, diff --git a/server/services/email/smtp.py b/server/services/email/smtp.py index 8a23b80..462dc02 100644 --- a/server/services/email/smtp.py +++ b/server/services/email/smtp.py @@ -3,14 +3,16 @@ class SMTPConfig: - def __init__(self, smtp_server, smtp_port, username, password): + def __init__(self, smtp_server, smtp_port, username, password, use_tls=True, use_auth=True): self.smtp_server = smtp_server self.smtp_port = smtp_port self.username = username self.password = password + self.use_tls = use_tls + self.use_auth = use_auth def __repr__(self) -> str: - return f'SMTPConfig(smtp_server={self.smtp_server}, smtp_port={self.smtp_port}, username={self.username}, password={self.password})' # noqa + return f'SMTPConfig(smtp_server={self.smtp_server}, smtp_port={self.smtp_port}, username={self.username}, use_tls={self.use_tls}, use_auth={self.use_auth})' # noqa def send_email(*, smtp: SMTPConfig, from_addr, to_addr, subject, body, body_html=None): @@ -25,9 +27,11 @@ def send_email(*, smtp: SMTPConfig, from_addr, to_addr, subject, body, body_html try: server = SMTP(smtp.smtp_server, smtp.smtp_port) - if server.has_extn('STARTTLS'): + # if server.has_extn('STARTTLS'): + if smtp.use_tls: server.starttls() - if server.has_extn('AUTH'): + # if server.has_extn('AUTH'): + if smtp.use_auth: server.login(smtp.username, smtp.password) server.send_message(msg) server.quit() diff --git a/server/templates/email.html.j2 b/server/templates/email.html.j2 index 391249e..a6a0650 100644 --- a/server/templates/email.html.j2 +++ b/server/templates/email.html.j2 @@ -7,33 +7,19 @@
From
- {{ form.from_email(class="mdl-textfield__input", type="Email", id="fromemail") }} - + {{ form.from_addr(class="mdl-textfield__input", type="Email", id="from_addr") }} +
- {{ form.from_name(class="mdl-textfield__input", type="Name", id="fromname") }} - -
-
-
-
Subject
-
- {{ form.subject(class="mdl-textfield__input", type="Subject", id="subject") }} - -
-
-
-
Test Email
-
- {{ form.test_email(class="mdl-textfield__input", type="Test Email", id="testemail") }} - + {{ form.signature(class="mdl-textfield__input", type="Name", id="signature") }} +
-
Additional Text
+
Additional Info
- {{ form.additional_text(class="mdl-textfield__input", type="text", rows="10", id="addtext") }} - + {{ form.additional_info(class="mdl-textfield__input", type="text", rows="10", id="additional_info") }} +
diff --git a/tests/unit/test_email.py b/tests/unit/test_email.py index af043e1..76f3422 100644 --- a/tests/unit/test_email.py +++ b/tests/unit/test_email.py @@ -83,14 +83,14 @@ def test_send_html_email(mock_smtp): from aiosmtpd.handlers import Message as MessageHandler # noqa from email import message_from_string # noqa -_fake_email_config = SMTPConfig('127.0.0.1', 1025, 'user', 'pass') +_fake_email_config = SMTPConfig('127.0.0.1', 1025, 'user', 'pass', use_tls=False, use_auth=False) class CustomMessageHandler(MessageHandler): - received_message = None + received_message = [] def handle_message(self, message): - CustomMessageHandler.received_message = message_from_string(message.as_string()) + CustomMessageHandler.received_message.append(message_from_string(message.as_string())) @pytest.fixture() @@ -105,7 +105,7 @@ def smtp_server(): yield controller - CustomMessageHandler.received_message = None + CustomMessageHandler.received_message = [] controller.stop() thread.join() @@ -124,7 +124,7 @@ def test_send_plain_text_email_with_mock_smtp_server(smtp_server): assert success - msg = CustomMessageHandler.received_message + msg = CustomMessageHandler.received_message[0] assert msg is not None assert msg['From'] == TEST_FROM_EMAIL @@ -148,7 +148,7 @@ def test_send_html_email_with_mock_smtp_server(smtp_server): assert success - msg = CustomMessageHandler.received_message + msg = CustomMessageHandler.received_message[0] # check html content html = _get_content(msg, 'text/html') @@ -179,7 +179,7 @@ def test_send_seating_html_email_with_mock_smtp_server(smtp_server): assert success - msg = CustomMessageHandler.received_message + msg = CustomMessageHandler.received_message[0] assert msg['From'] == TEST_FROM_EMAIL assert msg['To'] == TEST_TO_EMAIL @@ -188,3 +188,7 @@ def test_send_seating_html_email_with_mock_smtp_server(smtp_server): html = _get_content(msg, 'text/html') assert html is not None assert test_seating_email.body in html + + +def test_send_email_for_exam_with_mock_smtp_server(smtp_server): + pass From 72d87b096b18b948598ed158b1075238591c8ef0 Mon Sep 17 00:00:00 2001 From: Reimirno Date: Sun, 19 Nov 2023 19:13:14 -0800 Subject: [PATCH 08/10] feat: allow override email recipient and subject at runtime --- .env.sample | 1 + server/forms.py | 2 ++ server/services/email/__init__.py | 8 ++++++-- server/templates/email.html.j2 | 13 ++++++++++++- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.env.sample b/.env.sample index 9ebf556..8bd7e12 100644 --- a/.env.sample +++ b/.env.sample @@ -6,6 +6,7 @@ LOCAL_TIMEZONE=US/Pacific GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= + CANVAS_SERVER_URL= CANVAS_CLIENT_ID= CANVAS_CLIENT_SECRET= diff --git a/server/forms.py b/server/forms.py index 33d19a5..4ba9cf6 100644 --- a/server/forms.py +++ b/server/forms.py @@ -86,6 +86,8 @@ class EmailForm(FlaskForm): from_addr = StringField('from_addr', [Email(), InputRequired()]) signature = StringField('signature', [InputRequired()]) additional_info = TextAreaField('additional_info') + override_subject = StringField('override_subject') + override_to_addr = StringField('override_to_addr') submit = SubmitField('send') diff --git a/server/services/email/__init__.py b/server/services/email/__init__.py index c205ff4..96036da 100644 --- a/server/services/email/__init__.py +++ b/server/services/email/__init__.py @@ -38,6 +38,7 @@ def email_students(exam: Exam, form): def _email_single_assignment(offering: Offering, exam: Exam, assignment: SeatAssignment, form) -> bool: seat_path = url_for('student_single_seat', seat_id=assignment.seat.id) seat_absolute_path = os.path.join(app.config.get('SERVER_BASE_URL'), seat_path) + student_email = \ templates.get_email(EmailTemplate.ASSIGNMENT_INFORM_EMAIL, {"EXAM": exam.display_name}, @@ -50,10 +51,13 @@ def _email_single_assignment(offering: Offering, exam: Exam, assignment: SeatAss "ADDITIONAL_INFO": form.additional_info.data, "SIGNATURE": form.signature.data}) + effective_to_addr = form.override_to_addr.data or assignment.student.email + effective_subject = form.override_subject.data or student_email.subject + return send_email(smtp=_email_config, from_addr=form.from_addr.data, - to_addr=assignment.student.email, - subject=student_email.subject, + to_addr=effective_to_addr, + subject=effective_subject, body=student_email.body, body_html=student_email.body if student_email.body_html else None) diff --git a/server/templates/email.html.j2 b/server/templates/email.html.j2 index a6a0650..5623a0a 100644 --- a/server/templates/email.html.j2 +++ b/server/templates/email.html.j2 @@ -15,13 +15,24 @@
-
+
Additional Info
{{ form.additional_info(class="mdl-textfield__input", type="text", rows="10", id="additional_info") }}
+
+
Overrides
+
+ {{ form.override_subject(class="mdl-textfield__input", type="Subject", id="subject") }} + +
+
+ {{ form.override_to_addr(class="mdl-textfield__input", type="Test Email", id="testemail") }} + +
+
{{ form.submit(class="mdl-button mdl-js-button mdl-button--raised") }}
From 94969e82adf1678e42b8f80d8d9929df27344b45 Mon Sep 17 00:00:00 2001 From: Reimirno Date: Sun, 19 Nov 2023 19:24:14 -0800 Subject: [PATCH 09/10] fix: better email error handling --- server/services/email/__init__.py | 83 +++++-------------------------- server/services/email/smtp.py | 5 +- server/views.py | 4 +- tests/unit/test_email.py | 10 ++-- 4 files changed, 23 insertions(+), 79 deletions(-) diff --git a/server/services/email/__init__.py b/server/services/email/__init__.py index 96036da..5daf238 100644 --- a/server/services/email/__init__.py +++ b/server/services/email/__init__.py @@ -17,6 +17,7 @@ def email_students(exam: Exam, form): ASSIGNMENT_PER_PAGE = 500 page_number = 1 + emailed_count = 0 while True: assignments = exam.get_assignments( @@ -29,10 +30,20 @@ def email_students(exam: Exam, form): page_number += 1 for assignment in assignments: - if _email_single_assignment(exam.offering, exam, assignment, form): + result = _email_single_assignment(exam.offering, exam, assignment, form) + if result[0]: + emailed_count += 1 assignment.emailed = True + else: + db.session.commit() + return result + else: + db.session.commit() - db.session.commit() + if emailed_count == 0: + return (False, "No unemailed assignments found.") + + return (True, ) def _email_single_assignment(offering: Offering, exam: Exam, assignment: SeatAssignment, form) -> bool: @@ -60,71 +71,3 @@ def _email_single_assignment(offering: Offering, exam: Exam, assignment: SeatAss subject=effective_subject, body=student_email.body, body_html=student_email.body if student_email.body_html else None) - - -# def email_students(exam, form): -# """Emails students in batches of 900""" -# sg = sendgrid.SendGridAPICslient(api_key=app.config['SENDGRID_API_KEY']) -# test = form.test_email.data -# while True: -# limit = 1 if test else 900 -# assignments = SeatAssignment.query.join(SeatAssignment.seat).join(Seat.room).filter( -# Room.exam_id == exam.id, -# not SeatAssignment.emailed -# ).limit(limit).all() -# if not assignments: -# break - -# data = { -# 'personalizations': [ -# { -# 'to': [ -# { -# 'email': test if test else assignment.student.email, -# } -# ], -# 'substitutions': { -# '-name-': assignment.student.first_name, -# '-room-': assignment.seat.room.display_name, -# '-seat-': assignment.seat.name, -# '-seatid-': str(assignment.seat.id), -# }, -# } -# for assignment in assignments -# ], -# 'from': { -# 'email': form.from_email.data, -# 'name': form.from_name.data, -# }, -# 'subject': form.subject.data, -# 'content': [ -# { -# 'type': 'text/plain', -# 'value': ''' -# Hi -name-, - -# Here's your assigned seat for {}: - -# Room: -room- - -# Seat: -seat- - -# You can view this seat's position on the seating chart at: -# {}/seat/-seatid-/ - -# {} -# '''.format(exam.display_name, app.config['DOMAIN'], form.additional_text.data) -# }, -# ], -# } - -# response = sg.client.mail.send.post(request_body=data) -# if response.status_code < 200 or response.status_code >= 400: -# raise Exception('Could not send mail. Status: {}. Body: {}'.format( -# response.status_code, response.body -# )) -# if test: -# return -# for assignment in assignments: -# assignment.emailed = True -# db.session.commit() diff --git a/server/services/email/smtp.py b/server/services/email/smtp.py index 462dc02..6d173f3 100644 --- a/server/services/email/smtp.py +++ b/server/services/email/smtp.py @@ -35,7 +35,6 @@ def send_email(*, smtp: SMTPConfig, from_addr, to_addr, subject, body, body_html server.login(smtp.username, smtp.password) server.send_message(msg) server.quit() - return True + return (True, ) except Exception as e: - print(f"Error sending email: {e}\n Config: \n{smtp}") - return False + return (False, f"Error sending email: {e.message}\n Config: \n{smtp}") diff --git a/server/views.py b/server/views.py index 1efadf0..8d8b991 100644 --- a/server/views.py +++ b/server/views.py @@ -330,7 +330,9 @@ def assign(exam): def email(exam): form = EmailForm() if form.validate_on_submit(): - email_students(exam, form) + success, payload = email_students(exam, form) + if not success: + return payload return redirect(url_for('students', exam=exam)) return render_template('email.html.j2', exam=exam, form=form) # endregion diff --git a/tests/unit/test_email.py b/tests/unit/test_email.py index 76f3422..f0061ba 100644 --- a/tests/unit/test_email.py +++ b/tests/unit/test_email.py @@ -38,7 +38,7 @@ def test_send_plain_text_email(mock_smtp): subject=TEST_SUBJECT, body=TEST_BODY) - assert success + assert success[0] # check the use of smtp server mock_smtp.assert_called_with(_email_config.smtp_server, _email_config.smtp_port) @@ -70,7 +70,7 @@ def test_send_html_email(mock_smtp): body=TEST_BODY, body_html=TEST_BODY_HTML) - assert success + assert success[0] msg = mock_smtp.return_value.send_message.call_args[0][0] html = _get_content(msg, 'text/html') @@ -122,7 +122,7 @@ def test_send_plain_text_email_with_mock_smtp_server(smtp_server): subject=TEST_SUBJECT, body=TEST_BODY) - assert success + assert success[0] msg = CustomMessageHandler.received_message[0] @@ -146,7 +146,7 @@ def test_send_html_email_with_mock_smtp_server(smtp_server): body=TEST_BODY, body_html=TEST_BODY_HTML) - assert success + assert success[0] msg = CustomMessageHandler.received_message[0] @@ -177,7 +177,7 @@ def test_send_seating_html_email_with_mock_smtp_server(smtp_server): body=test_seating_email.body, body_html=test_seating_email.body if test_seating_email.body_html else None) - assert success + assert success[0] msg = CustomMessageHandler.received_message[0] From bc4bffbd18cf10153cb73361cf418d8876570e13 Mon Sep 17 00:00:00 2001 From: Reimirno Date: Sun, 19 Nov 2023 19:32:31 -0800 Subject: [PATCH 10/10] chore: update doc related to email --- README.md | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/README.md b/README.md index 1db428d..40f0c99 100644 --- a/README.md +++ b/README.md @@ -105,27 +105,7 @@ Student and email data could come from different sources (Google Sheets, csv upl #### Emailing students -(Under Construction; content might be outdated) - -Students will receive an email that looks like - -``` -Hi -name-, - -Here's your assigned seat for -exam-: - -Room: -room- - -Seat: -seat- - -You can view this seat's position on the seating chart at: -/seat/-seatid-/ - --additional text- -``` - -The "additional text" is a good place to tell them what to do if they have an -issue with their seat, and to sign the email (e.g. "- Cal CS 61A Staff"). +In the emailing page, you should specify minimally sender's address and sender's signature. The signature is appended to the end of the email body. You can also specify additional text to be appended to the end of the email body (this is a good place to tell students what to do if they have concerns about their seating assignments). The email body is generated from a template. The template is a html file that you can edit. The template is located at `server/services/email/templates/assignment_inform_email.html` and its variables are automatically filled in. #### Get Roster Photos