forked from Cal-CS-61A-Staff/seating
-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat: Email with any SMTP server + Testing Email #13
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
2a69f0b
scaffolds smtp and templating
Reimirno e30cb90
feat: email testing
Reimirno 35b5389
feat: implement email_students
Reimirno 541230b
feat: setup mock smtp server for testing
Reimirno e546fab
chore: add comments
Reimirno 5723d52
feat: add seating email test
Reimirno 9efca3d
adds more seeding data and redo email form
Reimirno 72d87b0
feat: allow override email recipient and subject at runtime
Reimirno 94969e8
fix: better email error handling
Reimirno bc4bffb
chore: update doc related to email
Reimirno 86adf36
Merge branch 'main' into send-email
Reimirno 5ae41c0
Merge branch 'main' into send-email
Reimirno f408505
Merge branch 'main' into send-email
Reimirno File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,4 +6,5 @@ safety==1.10.3 | |
pip-audit==2.6.1 | ||
selenium==4.14.0 | ||
responses==0.23.3 | ||
aiosmtpd==1.4.4.post2 | ||
flask-fixtures==0.3.8 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,73 +1,73 @@ | ||
from server import app | ||
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 | ||
|
||
import sendgrid | ||
_email_config = SMTPConfig( | ||
app.config.get('EMAIL_SERVER'), | ||
app.config.get('EMAIL_PORT'), | ||
app.config.get('EMAIL_USERNAME'), | ||
app.config.get('EMAIL_PASSWORD') | ||
) | ||
|
||
from server import app | ||
from server.models import db, Room, Seat, SeatAssignment | ||
|
||
def email_students(exam: Exam, form): | ||
ASSIGNMENT_PER_PAGE = 500 | ||
page_number = 1 | ||
emailed_count = 0 | ||
|
||
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() | ||
assignments = exam.get_assignments( | ||
emailed=False, | ||
limit=ASSIGNMENT_PER_PAGE, | ||
offset=(page_number - 1) * ASSIGNMENT_PER_PAGE | ||
) | ||
if not assignments: | ||
break | ||
page_number += 1 | ||
|
||
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-, | ||
for assignment in assignments: | ||
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() | ||
|
||
Here's your assigned seat for {}: | ||
if emailed_count == 0: | ||
return (False, "No unemailed assignments found.") | ||
|
||
Room: -room- | ||
return (True, ) | ||
|
||
Seat: -seat- | ||
|
||
You can view this seat's position on the seating chart at: | ||
{}/seat/-seatid-/ | ||
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) | ||
|
||
{} | ||
'''.format(exam.display_name, app.config['DOMAIN'], form.additional_text.data) | ||
}, | ||
], | ||
} | ||
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_info.data, | ||
"SIGNATURE": form.signature.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() | ||
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=effective_to_addr, | ||
subject=effective_subject, | ||
body=student_email.body, | ||
body_html=student_email.body if student_email.body_html else None) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
from smtplib import SMTP | ||
from email.message import EmailMessage | ||
|
||
|
||
class SMTPConfig: | ||
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}, 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): | ||
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: | ||
server = SMTP(smtp.smtp_server, smtp.smtp_port) | ||
# if server.has_extn('STARTTLS'): | ||
if smtp.use_tls: | ||
server.starttls() | ||
# if server.has_extn('AUTH'): | ||
if smtp.use_auth: | ||
server.login(smtp.username, smtp.password) | ||
server.send_message(msg) | ||
server.quit() | ||
return (True, ) | ||
except Exception as e: | ||
return (False, f"Error sending email: {e.message}\n Config: \n{smtp}") | ||
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
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) | ||
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 |
33 changes: 33 additions & 0 deletions
33
server/services/email/templates/assignment_inform_email.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
<!DOCTYPE html> | ||
<html> | ||
<head> | ||
<title>Seating Assignment for {{COURSE}} {{EXAM}}</title> | ||
</head> | ||
<body> | ||
<p>Dear <strong>{{NAME}}</strong>,</p> | ||
|
||
<p>Here's your assigned seat for the upcoming exam:</p> | ||
|
||
<ul> | ||
<li>Course: <strong>{{COURSE}}</strong></li> | ||
<li>Exam: <strong>{{EXAM}}</strong></li> | ||
<li>Room: <strong>{{ROOM}}</strong></li> | ||
<li>Seat: <strong>{{SEAT}}</strong></li> | ||
</ul> | ||
|
||
<p>You can view this seat's position on the seating chart at:</p> | ||
<a href="{{URL}}">View Seating Chart</a> | ||
|
||
<p> | ||
If you have any questions or require further assistance, please do not | ||
hesitate to contact the relevant course staff. | ||
</p> | ||
|
||
<p>Good luck on your exam!</p> | ||
|
||
<p>{{ADDITIONAL_INFO}}</p> | ||
|
||
<p>Best,</p> | ||
<p>{{SIGNATURE}}</p> | ||
</body> | ||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"subject": "Your Exam Seating Assignment for {{EXAM}}", | ||
"body_path": "assignment_inform_email.html", | ||
"body_html": "true" | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if it makes sense to use
None
for default values?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we don't specify a default value it is already
None
.The getenv helper I wrote will raise exception when the variable is
None
.Providing a non-
None
default value is a way to make this var optional.But they are not really optional - if you want the email to be actually sent then you have to provide it.
But in DEV env then you don't want email to be sent. They don't need to be there.
Erhh sounds a bit messy. Maybe for the email env vars I should just put them into
DevelopmentConfig