Skip to content
Snippets Groups Projects
Commit cc848bda authored by Tim Repke's avatar Tim Repke
Browse files

add email handling

parent acf0e317
No related branches found
No related tags found
1 merge request!66Master
Pipeline #2301 failed
NACSOS_LOG_CONF_FILE="config/logging.toml"
NACSOS_SERVER__WEB_URL="https://localhost:8080"
NACSOS_SERVER__HOST="localhost"
NACSOS_SERVER__PORT=8081
NACSOS_SERVER__CORS_ORIGINS='["http://localhost:8080", "http://127.0.0.1:8080", "http://localhost:8081", "http://127.0.0.1:8081"]'
......@@ -29,3 +30,11 @@ NACSOS_OA_DB__PASSWORD="password"
NACSOS_OA_DB__DATABASE="openalex"
NACSOS_OA_SOLR="http://localhost:8983/solr/openalex"
NACSOS_EMAIL__ENABLED=false
NACSOS_EMAIL__SMTP_TLS=true
NACSOS_EMAIL__SMTP_PORT:587
NACSOS_EMAIL__SMTP_HOST:
NACSOS_EMAIL__SMTP_USER:
NACSOS_EMAIL__SMTP_PASSWORD:
NACSOS_EMAIL__SENDER:"NACSOS <noreply@mcc-berlin.net>"
fastapi==0.103.1
fastapi==0.104.1
hypercorn==0.14.4
toml==0.10.2
email-validator==2.0.0.post2
......@@ -7,4 +7,5 @@ passlib[bcrypt]==1.7.4
pymitter==0.4.2
uvicorn==0.23.2
python-multipart==0.0.6
aiosmtplib==3.0.0
nacsos_data[scripts,server,utils] @ git+ssh://git@gitlab.pik-potsdam.de/mcc-apsis/nacsos/nacsos-data.git@v0.12.1
......@@ -12,6 +12,7 @@ from .routes import stats
from .routes import export
from .routes import search
from .routes import evaluation
from .routes import mailing
# this router proxies all /api endpoints
router = APIRouter()
......@@ -54,3 +55,6 @@ router.include_router(search.router, prefix='/search', tags=['search'])
# route for computing evaluation metrics and other statistics
router.include_router(evaluation.router, prefix='/eval', tags=['evaluation'])
# route for sending emails
router.include_router(mailing.router, prefix='/mail', tags=['mailing'])
from fastapi import APIRouter, Depends, BackgroundTasks
from fastapi.responses import PlainTextResponse
from nacsos_data.util.errors import NotFoundError
from sqlalchemy import select, func as F
from sqlalchemy.ext.asyncio import AsyncSession
from nacsos_data.db.crud.users import read_user_by_name
from nacsos_data.db.schemas import User, Assignment, AssignmentScope, Project, AnnotationScheme
from nacsos_data.models.users import UserModel
from nacsos_data.util.auth import UserPermissions
from server.util.config import settings
from server.util.email import EmailNotSentError, send_message, send_message_sync
from server.util.logging import get_logger
from server.util.security import (
auth_helper,
get_current_active_superuser,
UserPermissionChecker
)
from server.data import db_engine
logger = get_logger('nacsos.api.route.mailing')
router = APIRouter()
logger.debug('Setup nacsos.api.route.mailing router')
@router.post('/reset-password/{username}', response_class=PlainTextResponse)
async def reset_password(username: str) -> str:
user = await read_user_by_name(username, engine=db_engine)
if user is not None and user.email is not None:
# First, clear existing auth tokens
# Note: This can be used to troll users by deactivating their sessions. Trusting sensible use for now.
await auth_helper.clear_tokens_by_user(username=username)
# Create new token
token = await auth_helper.refresh_or_create_token(username=username,
token_lifetime_minutes=3 * 60)
try:
await send_message(recipients=[user.email],
subject='[NACSOS] Reset password',
message=f'Dear {user.full_name},\n'
f'You are receiving this message because you or someone else '
f'tried to reset your password.\n'
f'We closed all your active sessions, so you will have to log in again.\n'
f'\n'
f'You can use the following link within the next 3h to reset your password:\n'
f'{settings.SERVER.WEB_URL}/#/password-reset/{token.token_id}\n'
f'\n'
f'Sincerely,\n'
f'The Platform')
except EmailNotSentError:
pass
else:
# Do nothing as to not leak usernames outside by guessing
# raise NotFoundError(f'No user by that name: {username}')
pass
return 'OK'
@router.post('/welcome', response_class=PlainTextResponse)
async def welcome_mail(username: str,
password: str,
superuser: UserModel = Depends(get_current_active_superuser)) -> bool:
user = await read_user_by_name(username, engine=db_engine)
if user is not None and user.email is not None:
# Create new token
token = await auth_helper.refresh_or_create_token(username=username,
token_lifetime_minutes=3 * 60)
return await send_message(recipients=[user.email],
subject='[NACSOS] Welcome to the platform',
message=f'Dear {user.full_name},\n'
f'I created an account on our scoping platform for you.\n '
f'\n'
f'Username: {user.username}.\n'
f'Password: {password}\n'
f'\n'
f'You can change your password after logging in by opening the user menu at '
f'the top right and clicking "edit profile".\n'
f'\n'
f'You can use the following link within the next 3h to reset your password:\n'
f'{settings.SERVER.WEB_URL}/#/password-reset/{token.token_id}\n'
f'\n'
f'We are working on expanding the documentation for the platform here:\n'
f'https://apsis.mcc-berlin.net/nacsos-docs/'
f'\n'
f'Sincerely,\n'
f'The Platform')
@router.post('/assignment-reminder', response_model=list[str])
async def remind_users_assigment(assignment_scope_id: str,
background_tasks: BackgroundTasks,
permissions: UserPermissions = Depends(UserPermissionChecker('annotations_edit'))) \
-> list[str]:
session: AsyncSession
async with db_engine.session() as session:
stmt_info = (
select(AssignmentScope.name.label('scope_name'),
Project.name.label('project_name'))
.join(AnnotationScheme, AnnotationScheme.annotation_scheme_id == AssignmentScope.annotation_scheme_id)
.join(Project, Project.project_id == AnnotationScheme.project_id)
.where(AssignmentScope.assignment_scope_id == assignment_scope_id,
Project.project_id == permissions.permissions.project_id)
)
info = (await session.execute(stmt_info)).mappings().first()
if info is None:
raise NotFoundError('No data associated with this project.')
stmt = (select(User.full_name, User.email, User.username,
F.count(Assignment.assignment_id).label('num_assignments'),
F.count(Assignment.assignment_id).filter(Assignment.status == 'OPEN').label('num_open'),
F.count(Assignment.assignment_id).filter(Assignment.status == 'FULL').label('num_done'),
F.count(Assignment.assignment_id).filter(Assignment.status == 'PARTIAL').label('num_part'))
.join(Assignment, Assignment.user_id == User.user_id)
.where(Assignment.assignment_scope_id == assignment_scope_id)
.group_by(User.full_name, User.email, User.username))
result = (await session.execute(stmt)).mappings().all()
reminded_users = []
for res in result:
if res['num_open'] > 0:
logger.debug(f'Trying to remind {res}')
background_tasks.add_task(
send_message,
sender=None,
recipients=[res['email']],
subject='[NACSOS] Assignments waiting for you',
message=f'Dear {res["full_name"]},\n'
f'In the project "{info["project_name"]}", in the scope "{info["scope_name"]}", '
f'we created {res["num_assignments"]} assignments for you.\n '
f'\n'
f'So far, you fully finished {res["num_done"]}, '
f'{res["num_part"]} are partially done,'
f'and {res["num_open"]} are still open.\n'
f'\n'
f'Please head over to the platform to annotate the documents assigned to you: '
f'{settings.SERVER.WEB_URL}/#/project/annotate\n'
f'\n'
f'Sincerely,\n'
f'The Platform'
)
reminded_users.append(res['username'])
else:
logger.debug(f'Not reminding {res}')
return reminded_users
......@@ -21,6 +21,7 @@ class ServerConfig(BaseModel):
PORT: int = 8080 # port for this serve to listen at
DEBUG_MODE: bool = False # set this to true in order to get more detailed logs
WORKERS: int = 2 # number of worker processes
WEB_URL: str = 'https://localhost' # URL to the web frontend (without trailing /)
STATIC_FILES: str = '../nacsos-web/dist/' # path to the static files to be served
OPENAPI_FILE: str = '/openapi.json' # absolute URL path to openapi.json file
OPENAPI_PREFIX: str = '' # see https://fastapi.tiangolo.com/advanced/behind-a-proxy/
......@@ -77,26 +78,14 @@ class DatabaseConfig(BaseModel):
class EmailConfig(BaseModel):
ENABLED: bool = False
SMTP_TLS: bool = True
SMTP_PORT: int | None = None
SMTP_HOST: str | None = None
SMTP_USER: str | None = None
SMTP_PASSWORD: str | None = None
SENDER_ADDRESS: EmailStr | None = None
SENDER_NAME: str | None = 'NACSOS'
ENABLED: bool = False
@field_validator('ENABLED', mode='before')
@classmethod
def get_emails_enabled(cls, v: str | None, info: ValidationInfo) -> bool:
assert info.config is not None
return bool(
info.data.get('SMTP_HOST')
and info.data.get('SMTP_PORT')
and info.data.get('SENDER_ADDRESS')
)
TEST_USER: EmailStr = 'test@nacsos.eu'
SENDER: str | None = 'NACSOS <noreply@mcc-berlin.net>'
ADMINS: list[str] | None = None
class UsersConfig(BaseModel):
......@@ -128,7 +117,7 @@ class Settings(BaseSettings):
# URL including path to OpenAlex collection
OA_SOLR: AnyHttpUrl = 'http://localhost:8983/solr/openalex' # type: ignore[assignment]
# EMAIL: EmailConfig
EMAIL: EmailConfig
LOG_CONF_FILE: str = 'config/logging.conf'
LOGGING_CONF: dict[str, Any] | None = None
......
# TODO integrate this somehow in a sensible way
# https://fastapi.tiangolo.com/tutorial/background-tasks/
# https://github.com/tiangolo/full-stack-fastapi-postgresql/blob/490c554e23343eec0736b06e59b2108fdd057fdc/%7B%7Bcookiecutter.project_slug%7D%7D/backend/app/app/utils.py
import logging
from email.message import EmailMessage
from smtplib import (
SMTP as SMTPSync,
SMTP_SSL,
SMTPHeloError as SMTPHeloErrorOrig,
SMTPNotSupportedError,
SMTPDataError,
SMTPRecipientsRefused as SMTPRecipientsRefusedOrig,
SMTPSenderRefused as SMTPSenderRefusedOrig,
SMTPException as SMTPExceptionOrig
)
from aiosmtplib import (
SMTP,
SMTPResponseException,
SMTPSenderRefused,
SMTPRecipientsRefused,
SMTPException,
SMTPAuthenticationError,
SMTPNotSupported,
SMTPConnectTimeoutError,
SMTPConnectError,
SMTPConnectResponseError,
SMTPServerDisconnected,
SMTPHeloError, SMTPTimeoutError
)
from server.util.config import settings
logger = logging.getLogger('server.util.email')
class EmailNotSentError(Exception):
"""
Thrown when an email was not sent for some reason
"""
pass
def construct_email(recipients: list[str],
subject: str,
message: str,
sender: str | None = None) -> EmailMessage:
if sender is None:
sender = settings.EMAIL.SENDER
email = EmailMessage()
email.set_content(message)
email['Subject'] = subject
email['From'] = sender
email['To'] = ', '.join(recipients)
return email
async def send_message(recipients: list[str],
subject: str,
message: str,
sender: str | None = None) -> bool:
email = construct_email(sender=sender, recipients=recipients, subject=subject, message=message)
return await send_email(email)
async def send_email(email: EmailMessage) -> bool:
if not settings.EMAIL.ENABLED:
raise EmailNotSentError(f'Mailing system inactive, '
f'email with subject "{email["Subject"]}" not sent to {email["To"]}')
if email['From'] is None:
del email['From']
email['From'] = settings.EMAIL.SENDER
client = SMTP(hostname=settings.EMAIL.SMTP_HOST,
port=settings.EMAIL.SMTP_PORT,
use_tls=settings.EMAIL.SMTP_TLS,
start_tls=None,
username=settings.EMAIL.SMTP_USER,
password=settings.EMAIL.SMTP_PASSWORD)
try:
await client.connect()
logger.debug(f'Trying to send email to {email["To"]} with subject "{email["Subject"]}"')
status = await client.send_message(email)
logger.debug(status)
# await client.quit() # FIXME: Is this necessary? Docs say yes, but then it doesn't work...
logger.info(f'Successfully sent email to {email["To"]} with subject "{email["Subject"]}"')
return True
except (SMTPRecipientsRefused, SMTPResponseException, ValueError, SMTPException, SMTPTimeoutError,
SMTPAuthenticationError, SMTPNotSupported, SMTPConnectTimeoutError, SMTPConnectError,
SMTPConnectResponseError, SMTPServerDisconnected, SMTPHeloError, SMTPSenderRefused) as e:
logger.warning(f'Failed sending email to {email["To"]} with subject "{email["Subject"]}"')
logger.error(e)
await client.quit()
raise EmailNotSentError(f'Email with subject "{email["Subject"]}" not sent to {email["To"]} because of "{e}"')
def send_message_sync(recipients: list[str],
subject: str,
message: str,
sender: str | None = None) -> bool:
email = construct_email(sender=sender, recipients=recipients, subject=subject, message=message)
return send_email_sync(email)
def send_email_sync(email: EmailMessage) -> bool:
if not settings.EMAIL.ENABLED:
raise EmailNotSentError(f'Mailing system inactive, '
f'email with subject "{email["Subject"]}" not sent to {email["To"]}')
if email['From'] is None:
del email['From']
email['From'] = settings.EMAIL.SENDER
try:
if not settings.EMAIL.SMTP_TLS:
client = SMTP_SSL(host=settings.EMAIL.SMTP_HOST,
port=settings.EMAIL.SMTP_PORT)
else:
client = SMTPSync(host=settings.EMAIL.SMTP_HOST,
port=settings.EMAIL.SMTP_PORT)
with client as smtp:
smtp.login(user=settings.EMAIL.SMTP_USER,
password=settings.EMAIL.SMTP_PASSWORD)
smtp.connect()
logger.info(f'Trying to send email to {email["To"]} with subject "{email["Subject"]}"')
status = smtp.send_message(email)
logger.debug(status)
logger.info(f'Successfully sent email to {email["To"]} with subject "{email["Subject"]}"')
return True
except (SMTPHeloErrorOrig, SMTPRecipientsRefusedOrig, SMTPSenderRefusedOrig,
SMTPDataError, SMTPNotSupportedError, SMTPExceptionOrig) as e:
logger.warning(f'Failed sending email to {email["To"]} with subject "{email["Subject"]}"')
logger.error(e)
raise EmailNotSentError(f'Email with subject "{email["Subject"]}" not sent to {email["To"]} because of "{e}"')
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment