Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/admin/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ def email():
form.text.data,
reason=reason,
)
db.session.commit()
flash(f"Email queued for sending to {len(users)} users")
return redirect(url_for(".email"))

Expand Down
45 changes: 39 additions & 6 deletions apps/base/scheduled_tasks.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,66 @@
from flask import current_app as app
from sqlalchemy import func, select

from main import db, mail
from models.email import EmailJobRecipient
from models.email import Email, EmailJobRecipient
from models.scheduled_task import scheduled_task
from models.volunteer.notify import VolunteerNotifyRecipient

from ..config import config


@scheduled_task(minutes=1)
def send_emails():
"""Send queued emails, allowing for failure"""
def send_transactional_emails():
"""
Send queued non-bulk emails, allowing for failure.

As the job only runs once a minute, this isn't suitable for time-sensitive emails,
but they'll usually be sent in-line anyway.
"""
count = 0

emails = list(db.session.scalars(select(Email).where(Email.sent_at.is_(None))))
for email in emails:
count += send_transactional_email(email)
return count


def send_transactional_email(email: Email) -> int:
sent_count: int = mail.send_mail(
subject=email.subject,
from_email=email.from_email,
recipient_list=[email.recipient.email],
message=email.text_body,
html_message=email.html_body,
fail_silently=True,
)
if sent_count > 0:
email.sent_at = func.now()
db.session.commit()
return sent_count


@scheduled_task(minutes=1)
def send_bulk_emails():
"""Send queued bulk emails, allowing for failure"""
count = 0

# Sends via apps/common/backends/bulk.py
with mail.get_connection(app.config.get("BULK_MAIL_BACKEND")) as conn:
for rec in EmailJobRecipient.query.filter(EmailJobRecipient.sent == False):
count += send_email(conn, rec)
count += send_bulk_email(conn, rec)
return count


def send_email(conn, rec):
def send_bulk_email(conn, rec):
sent_count = mail.send_mail(
subject=rec.job.subject,
message=rec.job.text_body,
html_message=rec.job.html_body,
from_email=config.from_email("CONTACT_EMAIL"),
recipient_list=[rec.user.email],
fail_silently=True,
connection=conn,
html_message=rec.job.html_body,
)
if sent_count > 0:
rec.sent = True
Expand Down
12 changes: 8 additions & 4 deletions apps/cfp_review/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from wtforms import FormField

from apps.common import get_next_url
from apps.common.email import enqueue_email
from main import db, external_url, get_or_404
from models.content import (
SCHEDULE_ITEM_INFOS,
Expand Down Expand Up @@ -391,15 +392,18 @@ def send_email_for_proposal(proposal: Proposal, reason: ProposalEmailReason) ->

app.logger.info("Sending %s email for proposal %s", reason, proposal.id)

msg = EmailMessage(subject, from_email=from_email_, to=[proposal.user.email])
msg.body = render_template(
text_body = render_template(
template,
user=proposal.user,
proposal=proposal,
reserve_ticket_link=app.config["RESERVE_LIST_TICKET_LINK"],
)

msg.send()
enqueue_email(
recipient=proposal.user,
from_email=from_email_,
subject=subject,
text_body=text_body,
)


@cfp_review.route("/proposals/<int:proposal_id>/convert", methods=["GET", "POST"])
Expand Down
27 changes: 20 additions & 7 deletions apps/common/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from markupsafe import Markup

from main import db, mail
from models.email import EmailJob, EmailJobRecipient
from models.email import Email, EmailJob, EmailJobRecipient
from models.user import User

from ..config import config as app_config

Expand Down Expand Up @@ -135,15 +136,27 @@ def preview_trusted_email(preview_address, subject, body):


def enqueue_trusted_emails(users, subject, body, **kwargs):
"""Queue an email for sending by the background email worker."""
"""Queue a bulk email for sending by the background email worker."""
job = EmailJob(
subject,
format_trusted_plaintext_email(body, **kwargs),
format_trusted_html_email(body, subject, **kwargs),
subject=subject,
text_body=format_trusted_plaintext_email(body, **kwargs),
html_body=format_trusted_html_email(body, subject, **kwargs),
)
db.session.add(job)

for user in users:
db.session.add(EmailJobRecipient(job, user))
db.session.add(EmailJobRecipient(job=job, user=user))


db.session.commit()
def enqueue_email(
recipient: User, from_email: str, subject: str, text_body: str, html_body: Markup | None = None
) -> None:
"""Queue a transactional email for sending by the background email worker."""
email = Email(
recipient=recipient,
from_email=from_email,
subject=subject,
text_body=text_body,
html_body=html_body,
)
db.session.add(email)
1 change: 1 addition & 0 deletions apps/villages/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ def admin_email_owners() -> ResponseReturnValue:

if form.send.data is True:
enqueue_trusted_emails(users, form.subject.data, form.text.data)
db.session.commit()
flash(f"Email queued for sending to {users.count()} users")
return redirect(url_for(".admin_email_owners"))

Expand Down
43 changes: 43 additions & 0 deletions migrations/versions/c039b5838730_add_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Add email

Revision ID: c039b5838730
Revises: a1fe3b750f1e
Create Date: 2026-05-03 20:24:01.858029

"""

# revision identifiers, used by Alembic.
revision = 'c039b5838730'
down_revision = 'a1fe3b750f1e'

from alembic import op
import sqlalchemy as sa


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('email',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('recipient_id', sa.Integer(), nullable=False),
sa.Column('from_email', sa.String(), nullable=False),
sa.Column('subject', sa.String(), nullable=False),
sa.Column('text_body', sa.String(), nullable=False),
sa.Column('html_body', sa.String(), nullable=True),
sa.Column('created', sa.DateTime(), nullable=False),
sa.Column('sent_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['recipient_id'], ['user.id'], name=op.f('fk_email_recipient_id_user')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_email'))
)
with op.batch_alter_table('email', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_email_sent_at'), ['sent_at'], unique=False)

# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('email', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_email_sent_at'))

op.drop_table('email')
# ### end Alembic commands ###
31 changes: 21 additions & 10 deletions models/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from . import BaseModel, naive_utcnow

__all__ = ["EmailJob", "EmailJobRecipient"]
__all__ = ["Email", "EmailJob", "EmailJobRecipient"]


class EmailJob(BaseModel):
Expand All @@ -18,11 +18,6 @@ class EmailJob(BaseModel):
html_body: Mapped[str]
created: Mapped[datetime] = mapped_column(default=naive_utcnow)

def __init__(self, subject, text_body, html_body):
self.subject = subject
self.text_body = text_body
self.html_body = html_body

@classmethod
def get_export_data(cls):
jobs = cls.query.with_entities(
Expand All @@ -38,19 +33,35 @@ class EmailJobRecipient(BaseModel):
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
job_id: Mapped[int] = mapped_column(ForeignKey("email_job.id"))
# TODO: replace with a timestamp like Email
sent: Mapped[bool] = mapped_column(default=False)

user: Mapped[User] = relationship()
job: Mapped[EmailJob] = relationship()

def __init__(self, job, user):
self.job = job
self.user = user


EmailJob.recipient_count = column_property(
select(func.count(EmailJobRecipient.job_id))
.where(EmailJobRecipient.job_id == EmailJob.id)
.scalar_subquery(),
deferred=True,
)


class Email(BaseModel):
"""
Transactional (i.e. non-bulk) email, for things like CfP updates.
Having this in the DB allows us to link it to the rest of the database transaction.
"""

__tablename__ = "email"
id: Mapped[int] = mapped_column(primary_key=True)
recipient_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
from_email: Mapped[str]
subject: Mapped[str]
text_body: Mapped[str]
html_body: Mapped[str | None]
created: Mapped[datetime] = mapped_column(default=naive_utcnow)
sent_at: Mapped[datetime | None] = mapped_column(index=True)

recipient: Mapped[User] = relationship()
Loading