From 85c6cca26c68b80f3cc6865173676b90f104033d Mon Sep 17 00:00:00 2001 From: Cryptex-github <64497526+asze17@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:57:47 -0400 Subject: [PATCH 1/4] Begin implementing digest script --- backend/hoagiemail/api/mail_view.py | 15 +-- backend/hoagiemail/email/__init__.py | 12 ++ backend/hoagiemail/stuff/digest.py | 158 +++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 backend/hoagiemail/email/__init__.py create mode 100644 backend/hoagiemail/stuff/digest.py diff --git a/backend/hoagiemail/api/mail_view.py b/backend/hoagiemail/api/mail_view.py index 8eb7478..05c0baa 100644 --- a/backend/hoagiemail/api/mail_view.py +++ b/backend/hoagiemail/api/mail_view.py @@ -7,6 +7,7 @@ from rest_framework.response import Response from rest_framework.views import APIView +from hoagiemail.email import get_listservs from hoagiemail.email.limiter import Visitor from hoagiemail.email.mailjet_client import get_mailjet_client from hoagiemail.email.sanitize import sanitize_html @@ -120,20 +121,6 @@ def delete(self, request) -> Response: return Response({"status": "OK", "message": "Scheduled mail deleted successfully"}, status=status.HTTP_200_OK) -def get_listservs(): - """Returns list of college listserv recipients""" - return [ - {"Email": "BUTLERBUZZ@PRINCETON.EDU", "Name": "Butler"}, - {"Email": "WHITMANWIRE@PRINCETON.EDU", "Name": "Whitman"}, - {"Email": "RockyWire@PRINCETON.EDU", "Name": "Rocky"}, - {"Email": "Re-INNformer@PRINCETON.EDU", "Name": "Forbes"}, - {"Email": "westwire@princeton.edu", "Name": "NCW"}, - {"Email": "matheymail@PRINCETON.EDU", "Name": "Mathey"}, - {"Email": "yehyellowpages@princeton.edu", "Name": "Yeh"}, - {"Email": "hoagiemailgradstudents@princeton.edu", "Name": "hoagiemailgradstudents"}, - ] - - def create_message(mail_data, sender_email, to_email): """ Creates mailjet message structure. diff --git a/backend/hoagiemail/email/__init__.py b/backend/hoagiemail/email/__init__.py new file mode 100644 index 0000000..bd3ac5e --- /dev/null +++ b/backend/hoagiemail/email/__init__.py @@ -0,0 +1,12 @@ +def get_listservs(): + """Returns list of college listserv recipients""" + return [ + {"Email": "BUTLERBUZZ@PRINCETON.EDU", "Name": "Butler"}, + {"Email": "WHITMANWIRE@PRINCETON.EDU", "Name": "Whitman"}, + {"Email": "RockyWire@PRINCETON.EDU", "Name": "Rocky"}, + {"Email": "Re-INNformer@PRINCETON.EDU", "Name": "Forbes"}, + {"Email": "westwire@princeton.edu", "Name": "NCW"}, + {"Email": "matheymail@PRINCETON.EDU", "Name": "Mathey"}, + {"Email": "yehyellowpages@princeton.edu", "Name": "Yeh"}, + {"Email": "hoagiemailgradstudents@princeton.edu", "Name": "hoagiemailgradstudents"}, + ] diff --git a/backend/hoagiemail/stuff/digest.py b/backend/hoagiemail/stuff/digest.py new file mode 100644 index 0000000..3d35f43 --- /dev/null +++ b/backend/hoagiemail/stuff/digest.py @@ -0,0 +1,158 @@ +from datetime import timedelta +from typing import Final, TypedDict + +from hoagiemail.email import get_listservs +from hoagiemail.email.mailjet_client import mailjet_client + +REQUEST_TIMEOUT: Final[timedelta] = timedelta(seconds=10) +SUMMER: Final[bool] = False +SANDWITCH: Final[str] = "" +LOGO: Final[str] = "Hoagie Digest" + + +class UserInfo: + name: str + email: str + + def __init__(self, name: str, email: str) -> None: + self.name = name + self.email = email + + +class Digest: + title: str + category: str + contact: str + description: str + link: str + thumbnail: str + name: str + email: str + tags: list[str] + user: UserInfo + + def __init__( + self, + title: str, + category: str, + contact: str, + description: str, + link: str, + thumbnail: str, + name: str, + email: str, + tags: list[str], + user: UserInfo, + ) -> None: + self.title = title + self.category = category + self.contact = contact + self.description = description + self.link = link + self.thumbnail = thumbnail + self.name = name + self.email = email + self.tags = tags + self.user = user + + +def link(text: str, link: str) -> str: + return f"{text}" + + +def link_mail(text: str) -> str: + return link(text, "mailto:" + text) + + +def format_tag(text: str) -> str: + return f'{text.title()}' + + +def add_tags(email: str, tags: list[str]) -> str: + email += "
" + + for tag in tags: + email += format_tag(tag) + " " + + email += "
" + + return email + + +def format_message(message: Digest) -> str: + email = "" + name = message.name + + match message.category: + case "sale": + tags = message.tags + + if not tags: + tags = message.title.split(", ") + # There's a TODO: remove here that I am not sure if I should remove + + email += f"
{message.description}
" + email += f"Contact: {name} ({link_mail(message.email)})
" + add_tags(email, tags) + case "lost": + if message.thumbnail: + email += 'See Picture
' + + email += "" + message.tags[0].upper() + ": " + message.title + "
" + email += "
" + message.description + "
" + email += f"Contact: {name} ({link_mail(message.email)})
" + case _: + email += "" + message.title + "
" + email += "
" + message.description + "
" + email += f"From: {name} ({link_mail(message.email)})
" + add_tags(email, message.tags) + + return email + + +class MailRequest: + header: str + sender: str + body: str + email: str + + def __init__(self, header: str, sender: str, body: str, email: str) -> None: + self.header = header + self.sender = sender + self.body = body + self.email = email + + +MailJetData = TypedDict( + "MailJetData", + { + "From": dict[str, str], + "ReplyTo": dict[str, str], + "Cc": list[dict[str, str]], + "Subject": str, + "Text-part": str, + "Html-part": str, + "CustomID": str, + }, +) + + +def mail_request(request: MailRequest) -> None: + data: MailJetData = { + "From": {"Email": "hoagie@princeton.edu", "Name": request.sender}, + "ReplyTo": {"Email": request.email, "Name": request.sender}, + "Cc": get_listservs(), + "Subject": request.header, + "Text-part": request.body, + "Html-part": request.body, + "CustomID": "HoagieStuffDigest", + } + + res = mailjet_client.send.create(data={"Messages": [data]}) + res.raise_for_status() + + if res.status_code != 201: + raise RuntimeError("did not receive HTTP 201") + + if res.json()[0]["Status"] != "success": + raise RuntimeError("message send not successful") From 5ad28faf6061b8ee9c888622c0196714acacd6e8 Mon Sep 17 00:00:00 2001 From: Cryptex-github <64497526+asze17@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:47:55 -0400 Subject: [PATCH 2/4] Finish digest script --- backend/hoagiemail/stuff/digest.py | 293 +++++++++++++++++------------ 1 file changed, 172 insertions(+), 121 deletions(-) diff --git a/backend/hoagiemail/stuff/digest.py b/backend/hoagiemail/stuff/digest.py index 3d35f43..4f866d0 100644 --- a/backend/hoagiemail/stuff/digest.py +++ b/backend/hoagiemail/stuff/digest.py @@ -1,158 +1,209 @@ +import logging from datetime import timedelta -from typing import Final, TypedDict +from os import getenv +from typing import Final, Iterable, TypedDict + +from django.utils import timezone from hoagiemail.email import get_listservs -from hoagiemail.email.mailjet_client import mailjet_client +from hoagiemail.email.mailjet_client import get_mailjet_client +from hoagiemail.models import StuffPost REQUEST_TIMEOUT: Final[timedelta] = timedelta(seconds=10) SUMMER: Final[bool] = False SANDWITCH: Final[str] = "" LOGO: Final[str] = "Hoagie Digest" +IS_PRODUCTON: Final[bool] = getenv("HOAGIE_MODE") == "production" - -class UserInfo: - name: str - email: str - - def __init__(self, name: str, email: str) -> None: - self.name = name - self.email = email - - -class Digest: - title: str - category: str - contact: str - description: str - link: str - thumbnail: str - name: str - email: str - tags: list[str] - user: UserInfo - - def __init__( - self, - title: str, - category: str, - contact: str, - description: str, - link: str, - thumbnail: str, - name: str, - email: str, - tags: list[str], - user: UserInfo, - ) -> None: - self.title = title - self.category = category - self.contact = contact - self.description = description - self.link = link - self.thumbnail = thumbnail - self.name = name - self.email = email - self.tags = tags - self.user = user +logger = logging.getLogger(__name__) def link(text: str, link: str) -> str: - return f"{text}" + return f"{text}" def link_mail(text: str) -> str: - return link(text, "mailto:" + text) + return link(text, "mailto:" + text) def format_tag(text: str) -> str: - return f'{text.title()}' + return f'{text.title()}' -def add_tags(email: str, tags: list[str]) -> str: - email += "
" +def add_tags(email: str, tags: Iterable[str]) -> str: + email += "
" - for tag in tags: - email += format_tag(tag) + " " + for tag in tags: + email += format_tag(tag) + " " - email += "
" + email += "
" - return email + return email -def format_message(message: Digest) -> str: - email = "" - name = message.name +def format_message(message: StuffPost) -> str: + email = "" + name = str(message.user) + link_email = link_mail(message.user.email) - match message.category: - case "sale": - tags = message.tags + match str(message.category): + case "sale": + tags = [str(t) for t in message.tags.all()] - if not tags: - tags = message.title.split(", ") - # There's a TODO: remove here that I am not sure if I should remove + if not tags: + tags = message.title.split(", ") + # There's a TODO: remove here that I am not sure if I should remove - email += f"
{message.description}
" - email += f"Contact: {name} ({link_mail(message.email)})
" - add_tags(email, tags) - case "lost": - if message.thumbnail: - email += 'See Picture
' + email += f"
{message.description}
" + email += f"Contact: {name} ({link_email})
" + add_tags(email, tags) + case "lost": + if message.thumbnail: + email += 'See Picture
' - email += "" + message.tags[0].upper() + ": " + message.title + "
" - email += "
" + message.description + "
" - email += f"Contact: {name} ({link_mail(message.email)})
" - case _: - email += "" + message.title + "
" - email += "
" + message.description + "
" - email += f"From: {name} ({link_mail(message.email)})
" - add_tags(email, message.tags) + email += "" + message.tags[0].upper() + ": " + message.title + "
" + email += "
" + message.description + "
" + email += f"Contact: {name} ({link_email})
" + case _: + email += "" + message.title + "
" + email += "
" + message.description + "
" + email += f"From: {name} ({link_email})
" + add_tags(email, [str(t) for t in message.tags.all()]) - return email + return email -class MailRequest: - header: str - sender: str - body: str - email: str +MailJetData = TypedDict( + "MailJetData", + { + "From": dict[str, str], + "ReplyTo": dict[str, str], + "Cc": list[dict[str, str]], + "Subject": str, + "Text-part": str, + "Html-part": str, + "CustomID": str, + }, +) - def __init__(self, header: str, sender: str, body: str, email: str) -> None: - self.header = header - self.sender = sender - self.body = body - self.email = email +def mail_request(header: str, sender: str, body: str, email: str) -> None: + data: MailJetData = { + "From": {"Email": "hoagie@princeton.edu", "Name": sender}, + "ReplyTo": {"Email": email, "Name": sender}, + "Cc": get_listservs(), + "Subject": header, + "Text-part": body, + "Html-part": body, + "CustomID": "HoagieStuffDigest", + } + + res = get_mailjet_client().send.create(data={"Messages": [data]}) + res.raise_for_status() + + if res.status_code != 201: + raise RuntimeError("did not receive HTTP 201") + + if res.json()[0]["Status"] != "success": + raise RuntimeError("message send not successful") -MailJetData = TypedDict( - "MailJetData", - { - "From": dict[str, str], - "ReplyTo": dict[str, str], - "Cc": list[dict[str, str]], - "Subject": str, - "Text-part": str, - "Html-part": str, - "CustomID": str, - }, -) +def run_digest_script() -> None: + posts = StuffPost.objects.filter(has_sent=False) -def mail_request(request: MailRequest) -> None: - data: MailJetData = { - "From": {"Email": "hoagie@princeton.edu", "Name": request.sender}, - "ReplyTo": {"Email": request.email, "Name": request.sender}, - "Cc": get_listservs(), - "Subject": request.header, - "Text-part": request.body, - "Html-part": request.body, - "CustomID": "HoagieStuffDigest", - } - - res = mailjet_client.send.create(data={"Messages": [data]}) - res.raise_for_status() - - if res.status_code != 201: - raise RuntimeError("did not receive HTTP 201") - - if res.json()[0]["Status"] != "success": - raise RuntimeError("message send not successful") + if not posts: + logging.info("No messages found...Exiting...") + return + + digest: dict[str, list[StuffPost]] = {} + + for post in posts: + category_posts = digest.get(post.category.name, []) + category_posts.append(post) + digest[post.category.name] = category_posts + + if len(digest) < 5: + is_weekday = timezone.now().weekday() in {1, 3, 5} + + is_digest_day = is_weekday and not SUMMER + + if not is_digest_day: + logger.info("not a digest day...Exiting...") + return + + logger.info("5 or more digest posts...Running...") + + body = f""" +
+
{LOGO}
+ + { + '


Here is a digest of posts made to Hoagie Stuff

over past few days. It\'s Summer, so Hoagie is taking things slow.

' + if SUMMER + else '''


Here is a weekly digest of posts made to Hoagie Stuff, + from Sales to Lost & Found and more, sent every Tuesday, Thursday, and Saturday.

''' + } + +

+ Open Hoagie Stuff | + Add your message to next digest | + Give feedback +

+
+ """ + + if lost_posts := digest["lost"]: + body += """ +

🧭 Lost & Found

+
Access anytime through stuff.hoagie.io/lost
+ + """ + for i, post in enumerate(lost_posts): + body += format_message(post) + + if i == len(lost_posts) - 1: + body += "
" + + body += "
" + + if bulletin_posts := digest["bulletin"]: + body += """ +

✉️ Bulletins

+
Accessible anytime with stuff.hoagie.io/bulletins
+ """ + + for i, post in enumerate(bulletin_posts): + body += format_message(post) + + if i == len(bulletin_posts) - 1: + body += "
" + + body += "
" + + if len(digest) != 1: + body += f"

That's all! This could have been {len(digest)} emails in your inbox but instead it is just one!

" + + body += f""" +

You don't need to wait for the next digest to see what's new, check out the Hoagie Stuff + to keep up to date with the latest posts before others.

+
+ {SANDWITCH}
+
+ Powered by HoagieMail
+ In the Hoagie world, hoagies digest you! +
+
+
+ """ + + if IS_PRODUCTON: + mail_request( + header=f"📬 DIGEST {timezone.now().strftime('m/d')}: Sales, Lost & Found, and more!", + sender="Hoagie Mail", + body=body, + email="hoagie@princeton.edu", + ) + + logger.info("Sucessfully sent via Hoagie Mail") + + posts.update(has_sent=True) From ce6c6635fa1b69a063242fe8f5d6cc455c42d39b Mon Sep 17 00:00:00 2001 From: Cryptex-github <64497526+asze17@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:21:54 -0400 Subject: [PATCH 3/4] Fix time format --- backend/hoagiemail/stuff/digest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/hoagiemail/stuff/digest.py b/backend/hoagiemail/stuff/digest.py index 4f866d0..03a2f22 100644 --- a/backend/hoagiemail/stuff/digest.py +++ b/backend/hoagiemail/stuff/digest.py @@ -198,7 +198,7 @@ def run_digest_script() -> None: if IS_PRODUCTON: mail_request( - header=f"📬 DIGEST {timezone.now().strftime('m/d')}: Sales, Lost & Found, and more!", + header=f"📬 DIGEST {timezone.now().strftime('%m/%d')}: Sales, Lost & Found, and more!", sender="Hoagie Mail", body=body, email="hoagie@princeton.edu", From 6108b1cc217d834f48888831d1cf9af63e921a2f Mon Sep 17 00:00:00 2001 From: Alvin Sze <64497526+asze17@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:39:20 -0700 Subject: [PATCH 4/4] Fix "sandwitch" --- backend/hoagiemail/stuff/digest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/hoagiemail/stuff/digest.py b/backend/hoagiemail/stuff/digest.py index 03a2f22..fa887b1 100644 --- a/backend/hoagiemail/stuff/digest.py +++ b/backend/hoagiemail/stuff/digest.py @@ -11,7 +11,7 @@ REQUEST_TIMEOUT: Final[timedelta] = timedelta(seconds=10) SUMMER: Final[bool] = False -SANDWITCH: Final[str] = "" +HOAGIE_SANDWICH_LOGO: Final[str] = "" LOGO: Final[str] = "Hoagie Digest" IS_PRODUCTON: Final[bool] = getenv("HOAGIE_MODE") == "production" @@ -187,7 +187,7 @@ def run_digest_script() -> None:

You don't need to wait for the next digest to see what's new, check out the Hoagie Stuff to keep up to date with the latest posts before others.

- {SANDWITCH}
+ {HOAGIE_SANDWICH_LOGO}
Powered by HoagieMail
In the Hoagie world, hoagies digest you!