Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2026 arXiv
Copyright (c) 2026 arXiv, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
5 changes: 4 additions & 1 deletion app/email_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from arxiv.db.models import Submission, SubmissionCategory
from arxiv.taxonomy.definitions import CATEGORY_ALIASES

from app.schema import SubEmailData, SimplifiedNotification, CommentData, PromoteData, NewPropData, PropRespData, EmailTask, UserContact
from app.schema import SubEmailData, SimplifiedNotification, CommentData, PromoteData, NewPropData, PropRespData, CategoryRejectionData, EmailTask, UserContact

_ET = ZoneInfo(arxiv_settings.ARXIV_BUSINESS_TZ)
def _fmt_time(dt: datetime) -> str:
Expand All @@ -19,6 +19,7 @@ def _fmt_time(dt: datetime) -> str:
from app.templates.promote import render_promote_block
from app.templates.new_prop import render_new_prop_block
from app.templates.prop_resp import render_prop_resp_block
from app.templates.category_rejection import render_category_rejection_block
from app.templates.submission import render_submission_block
from app.templates.email_body import render_body

Expand Down Expand Up @@ -96,6 +97,8 @@ def render_change_block(change: SimplifiedNotification, user_name: str) -> tuple
return render_new_prop_block(change, user_name)
case PropRespData():
return render_prop_resp_block(change, user_name)
case CategoryRejectionData():
return render_category_rejection_block(change, user_name)
case _:
raise ValueError(f"unknown change data type: {type(change.data)}")

Expand Down
2 changes: 1 addition & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def main():
subscription_path = subscriber.subscription_path(settings.GCP_PROJECT_ID, settings.PUBSUB_SUBSCRIPTION_ID)
messages=get_messages(subscriber, subscription_path)
if len(messages)==0:
logger.warning("No messages found.")
logger.info("0 messages found.")
return

def ack(ids: list[str]) -> None:
Expand Down
42 changes: 37 additions & 5 deletions app/moderators.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from sqlalchemy import select

from arxiv.taxonomy.category import Category
from arxiv.taxonomy.definitions import CATEGORY_ALIASES, CATEGORIES_ACTIVE
from arxiv.db import Session
from arxiv.db.models import t_arXiv_moderators, TapirUser, TapirNickname

Expand Down Expand Up @@ -76,22 +77,53 @@ def get_all_moderators() -> tuple [dict[str, ToEmail], dict[str, ToEmail]]:

return all_archives, all_cats

_ALIAS_BY_CANONICAL = {v: k for k, v in CATEGORY_ALIASES.items()}

def who_to_email(category: Category, all_archives: dict[str, ToEmail], all_cats: dict[str, ToEmail])-> tuple[set[int], set[int]]:
"""determines who to include in an email for a given set of categories"""

email: set[int] = set()
reply_to: set[int] = set()
rolling_dont_email: set[int] = set()
rolling_dont_reply: set[int] = set()

cat_entry = all_cats.get(category.id, ToEmail())
archive_entry = all_archives.get(category.in_archive, ToEmail())

#add specific category moderators
#dont forget alaises
alias_id = _ALIAS_BY_CANONICAL.get(category.id)
if alias_id:
alias=CATEGORIES_ACTIVE[alias_id]
alias_cat_entry = all_cats.get(alias_id, ToEmail())
alias_archive_entry = all_archives.get(alias.in_archive, ToEmail())

# factory in email preferences
#priority: named category > alias category > named archive > alias archive
#each lower-priority group excludes anyone who opted out at a higher-priority level

#named category moderators
email.update(cat_entry.send_to)
reply_to.update(cat_entry.include_reply_to)

#add archive mods unless they have specifically declined
email.update(archive_entry.send_to - cat_entry.dont_send_to)
reply_to.update(archive_entry.include_reply_to - cat_entry.dont_include_reply_to)
rolling_dont_email.update(cat_entry.dont_send_to)
rolling_dont_reply.update(cat_entry.dont_include_reply_to)

#alias category moderators
if alias_id:
email.update(alias_cat_entry.send_to - rolling_dont_email)
reply_to.update(alias_cat_entry.include_reply_to - rolling_dont_reply)
rolling_dont_email.update(alias_cat_entry.dont_send_to)
rolling_dont_reply.update(alias_cat_entry.dont_include_reply_to)

#named archive moderators
email.update(archive_entry.send_to - rolling_dont_email)
reply_to.update(archive_entry.include_reply_to - rolling_dont_reply)
rolling_dont_email.update(archive_entry.dont_send_to)
rolling_dont_reply.update(archive_entry.dont_include_reply_to)

#alias archive moderators
if alias_id:
email.update(alias_archive_entry.send_to - rolling_dont_email)
reply_to.update(alias_archive_entry.include_reply_to - rolling_dont_reply)

return email, reply_to

Expand Down
45 changes: 32 additions & 13 deletions app/process.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""processes notifications"""
import logging
import json
from collections import Counter
from typing import Callable

from google.pubsub import ReceivedMessage
Expand All @@ -10,7 +11,7 @@
from app.config import settings
from app.email import send_email
from app.email_content import get_submission_info, render_email
from app.schema import NotificationParams, SimplifiedNotification, ConsolidatedNotifications, EmailTask, NotificationType, CommentData, PromoteData, NewPropData, PropRespData, UserContact, SubEmailData
from app.schema import NotificationParams, SimplifiedNotification, ConsolidatedNotifications, EmailTask, NotificationType, CommentData, PromoteData, NewPropData, PropRespData, CategoryRejectionData, UserContact, SubEmailData
from app.moderators import get_all_moderators, get_recipient_ids_for_categories, get_mod_emails

logger = logging.getLogger(__name__)
Expand All @@ -29,11 +30,13 @@ def _parse_message(payload)-> tuple[NotificationParams, SimplifiedNotification]:
data = PromoteData.model_validate(full_note.data)
case NotificationType.PROP_RESP:
data = PropRespData.model_validate(full_note.data)
case NotificationType.CATEGORY_REJECTION:
data = CategoryRejectionData.model_validate(full_note.data)
case _:
logger.error(f"unhandled action type: {full_note.action}, skipping message")
raise ValueError(f"unhandled action: {full_note.action}")

simple_note=SimplifiedNotification(time=full_note.time, user_id=full_note.user_id, data=data)
simple_note=SimplifiedNotification(time=full_note.time, user_id=full_note.user_id, action=full_note.action, data=data)
return full_note, simple_note

def _convert_messages(messages: list[ReceivedMessage], ack_fn: Callable[[list[str]], None]) -> dict[int, ConsolidatedNotifications]:
Expand Down Expand Up @@ -113,7 +116,6 @@ def _build_email_tasks(all_notifications: dict[int, ConsolidatedNotifications])
logger.error(f"submission {sub_id}: no tapir_users row for moderator ids {missing}")

if not to_emails:
logger.info(f"submission {sub_id}: no recipients after exclusions, skipping")
continue

#email data
Expand All @@ -131,13 +133,13 @@ def _send_email_tasks(
sub_infos: dict[int, SubEmailData],
ids_to_contact: dict[int, UserContact],
ack_fn: Callable[[list[str]], None],
) -> None:
) -> list[int]:
"""render and send emails, acking each submission only after its email sends"""

if settings.REDIRECT_EMAILS:
logger.info(f"REDIRECT_EMAILS active — all emails → {settings.REDIRECT_RECIPIENT}")

sent = 0
sent_sub_ids: list[int] = []
for task in email_tasks:
sub = sub_infos.get(task.submission_id)
if sub is None:
Expand Down Expand Up @@ -165,11 +167,11 @@ def _send_email_tasks(
)
ack_fn(task.notifications.ack_ids) # ack on success or all-refused (terminal)
if accepted:
sent += 1
sent_sub_ids.append(task.submission_id)
except Exception:
logger.exception(f"Failed to send email for submission {task.submission_id}, will redeliver")

logger.info(f"Sent {sent}/{len(email_tasks)} email(s) to relay")
return sent_sub_ids


def process_messages(messages: list[ReceivedMessage], ack_fn: Callable[[list[str]], None]) -> None:
Expand All @@ -186,7 +188,7 @@ def process_messages(messages: list[ReceivedMessage], ack_fn: Callable[[list[str
return

if not all_notifications:
logger.info("No valid notifications after parsing, nothing to send")
logger.warning("No valid notifications after parsing, nothing to send")
return

#determine who to email what
Expand All @@ -201,11 +203,11 @@ def process_messages(messages: list[ReceivedMessage], ack_fn: Callable[[list[str
skipped_sub_ids.append(sub_id)

if not email_tasks:
logger.warning(f"No emails to send — submissions with no recipients: {sorted(skipped_sub_ids)}")
logger.warning(f"No emails to send — no recipients after exclusions for submissions: {sorted(skipped_sub_ids)}")
return

if skipped_sub_ids:
logger.info(f"Skipped submissions (no recipients): {sorted(skipped_sub_ids)}")
logger.info(f"Skipped submissions (no recipients after exclusions): {sorted(skipped_sub_ids)}")

#fetch submission data — if batch query fails, skip all sends (will redeliver)
try:
Expand All @@ -215,6 +217,23 @@ def process_messages(messages: list[ReceivedMessage], ack_fn: Callable[[list[str
return

#send emails
_send_email_tasks(email_tasks, sub_infos, ids_to_contact, ack_fn)
logger.info(f"Processed {len(messages)} messages, built {len(email_tasks)} email task(s).")
return
sent_sub_ids = _send_email_tasks(email_tasks, sub_infos, ids_to_contact, ack_fn)

#stat logging
action_counts = Counter(
change.action.value
for n in all_notifications.values()
for change in n.changes
)
action_summary = ", ".join(f"{k}: {v}" for k, v in sorted(action_counts.items()))
total_actions = sum(action_counts.values())

if len(sent_sub_ids) > 20:
sub_summary = f"over 20 submissions"
else:
sub_summary = f"submission ids: {sorted(sent_sub_ids)}" if sent_sub_ids else "none"

logger.info(
f"Stats: {total_actions} actions ({action_summary}) | "
f"{len(sent_sub_ids)}/{len(email_tasks)} emails accepted by relay ({sub_summary})"
)
10 changes: 8 additions & 2 deletions app/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class NotificationType(str, Enum):
PROP_RESP = 'proposal-response'
NEW_PROP = 'new-proposal'
PROMOTE = 'category-promotion'
#TODO should rejections eventually send emails?
CATEGORY_REJECTION = 'category-rejection'

#the shape the data comes in the pubsub message
class NotificationParams(BaseModel):
Expand All @@ -37,11 +37,17 @@ class PromoteData(BaseModel):
promotion_type: Literal["primary", "secondary"]
category_change: str

class CategoryRejectionData(BaseModel):
category: str
rejection_type: Literal["reject", "accept_secondary", "cross_submission"]
category_change: str


class SimplifiedNotification(BaseModel):
time: datetime
user_id: int
data: Union[CommentData, PromoteData, PropRespData, NewPropData]
action: NotificationType
data: Union[CommentData, PromoteData, PropRespData, NewPropData, CategoryRejectionData]

@dataclass
class UserContact:
Expand Down
24 changes: 24 additions & 0 deletions app/templates/category_rejection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from app.schema import SimplifiedNotification, CategoryRejectionData
from app.email_content import _fmt_time

_REJECTION_LABELS = {
"reject": "removed from submission",
"accept_secondary": "demoted to secondary",
"cross_submission": "removed from cross submission",
}


def render_category_rejection_block(change: SimplifiedNotification, user_name: str) -> tuple[str, str]:
data: CategoryRejectionData = change.data
when = _fmt_time(change.time)
label = _REJECTION_LABELS.get(data.rejection_type, data.rejection_type)
text = (
f"[{when}] {user_name} rejected {data.category} ({label}):\n"
f" Change: {data.category_change}\n"
)
html_out = (
f"<p><strong>[{when}] {user_name}</strong> "
f"rejected {data.category} ({label})<br>\n"
f"Change: {data.category_change}</p>\n"
)
return text, html_out
Loading
Loading