Skip to content

Commit

Permalink
Action user.send_invitation_email implemented (#911)
Browse files Browse the repository at this point in the history
* Base for sending emails
* issue676: Action user.send_invitation_email

Co-authored-by: Finn Stutzenstein <finn.stutzenstein@hotmail.de>
  • Loading branch information
r-peschke and FinnStutzenstein committed Sep 6, 2021
1 parent e74558d commit 4d46523
Show file tree
Hide file tree
Showing 12 changed files with 1,530 additions and 0 deletions.
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,15 @@ COPY openslides_backend openslides_backend
COPY migrations/*.py migrations/
COPY migrations/migrations migrations/migrations/.

ENV EMAIL_HOST postfix
ENV EMAIL_PORT 25
# ENV EMAIL_HOST_USER username
# ENV EMAIL_HOST_PASSWORD secret
# EMAIL_CONNECTION_SECURITY use NONE, STARTTLS or SSL/TLS
ENV EMAIL_CONNECTION_SECURITY NONE
ENV EMAIL_TIMEOUT 5
ENV EMAIL_ACCEPT_SELF_SIGNED_CERTIFICATE false
ENV DEFAULT_FROM_EMAIL noreply@example.com

ENTRYPOINT ["./entrypoint.sh"]
CMD [ "python", "-m", "openslides_backend" ]
10 changes: 10 additions & 0 deletions dev/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,15 @@ EXPOSE 5678
ENV OPENSLIDES_DEVELOPMENT 1
ENV PYTHONPATH /app

ENV EMAIL_HOST postfix
ENV EMAIL_PORT 25
# ENV EMAIL_HOST_USER username
# ENV EMAIL_HOST_PASSWORD secret
# EMAIL_CONNECTION_SECURITY use NONE, STARTTLS or SSL/TLS
ENV EMAIL_CONNECTION_SECURITY NONE
ENV EMAIL_TIMEOUT 5
ENV EMAIL_ACCEPT_SELF_SIGNED_CERTIFICATE false
ENV DEFAULT_FROM_EMAIL noreply@example.com

ENTRYPOINT ["./entrypoint.sh"]
CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5678", "openslides_backend"]
1 change: 1 addition & 0 deletions dev/requirements_development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ pyyaml==5.4.1
pip-check==2.6
autoflake==1.4
debugpy==1.2.1
aiosmtpd==1.4.2

--requirement ../requirements.txt
1 change: 1 addition & 0 deletions openslides_backend/action/actions/user/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
generate_new_password,
merge_together,
reset_password_to_default,
send_invitation_email,
set_password,
set_password_self,
set_present,
Expand Down
19 changes: 19 additions & 0 deletions openslides_backend/action/actions/user/helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import Any, Dict


def get_user_name(instance: Dict[str, Any]) -> str:
"""Methods gets short name, combined name or whatever you call it from
instance-dict, which should contain first_name, last_name, username and title
Analogue to __str__ method from Openslides3
"""
first_name = instance.get("first_name", "").strip()
last_name = instance.get("last_name", "").strip()

if first_name and last_name:
name = " ".join((first_name, last_name))
else:
name = first_name or last_name or instance.get("username", "")

if title := instance.get("title", "").strip():
name = " ".join([title, name])
return name
218 changes: 218 additions & 0 deletions openslides_backend/action/actions/user/send_invitation_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
from collections import defaultdict
from email.headerregistry import Address
from smtplib import (
SMTPAuthenticationError,
SMTPDataError,
SMTPRecipientsRefused,
SMTPSenderRefused,
SMTPServerDisconnected,
)
from ssl import SSLCertVerificationError
from time import time
from typing import Any, Dict, Optional, Tuple, Union

from fastjsonschema import JsonSchemaException

from ....models.models import User
from ....permissions.permissions import Permissions
from ....shared.exceptions import DatastoreException, MissingPermission
from ....shared.interfaces.write_request import WriteRequest
from ....shared.patterns import Collection, FullQualifiedId
from ....shared.schema import required_id_schema
from ...generics.update import UpdateAction
from ...mixins.send_email_mixin import EmailMixin, EmailSettings
from ...util.default_schema import DefaultSchema
from ...util.register import register_action
from ...util.typing import ActionData, ActionResults
from .helper import get_user_name


@register_action("user.send_invitation_email")
class UserSendInvitationMail(EmailMixin, UpdateAction):
"""
Action send an invitation mail to a user.
"""

model = User()
schema = DefaultSchema(User()).get_update_schema(
additional_required_fields={"meeting_id": required_id_schema},
)
permission = Permissions.User.CAN_MANAGE

def perform(
self, action_data: ActionData, user_id: int, internal: bool = False
) -> Tuple[Optional[WriteRequest], Optional[ActionResults]]:
self.user_id = user_id
self.index = 0
if not EmailMixin.check_email(EmailSettings.default_from_email):
result = {
"sent": False,
"message": f"email {EmailSettings.default_from_email} is not a valid sender email address.",
}
self.results.append(result)
return (None, self.results)

try:
with EmailMixin.get_mail_connection() as mail_client:
self.index = -1
self.mail_client = mail_client
for instance in action_data:
self.index += 1
result = self.get_initial_result_false(instance)
try:
self.validate_instance(instance)
self.check_permissions(instance)
instance = self.update_instance(instance)
result = instance.pop("result")
except SMTPRecipientsRefused as e:
result["message"] = f"SMTPRecipientsRefused: {str(e)}"
except SMTPServerDisconnected as e:
result[
"message"
] = f"SMTPServerDisconnected: {str(e)} during transmission"
except JsonSchemaException as e:
result["message"] = f"JsonSchema: {str(e)}"
except DatastoreException as e:
result["message"] = f"DatastoreException: {str(e)}"
except MissingPermission as e:
result["message"] = e.message
except SMTPDataError as e:
result["message"] = f"SMTPDataError: {str(e)}"

if result["sent"]:
write_request = self.create_write_requests(instance)
self.write_requests.extend(write_request)

self.results.append(result)
except SMTPAuthenticationError as e:
result = {"sent": False, "message": f"SMTPAuthenticationError: {str(e)}"}
self.results.append(result)
except SMTPSenderRefused as e:
result = {
"sent": False,
"message": f"SMTPSenderRefused: {str(e)}",
}
self.results.append(result)
except ConnectionRefusedError as e:
result = {"sent": False, "message": f"ConnectionRefusedError: {str(e)}"}
self.results.append(result)
except SSLCertVerificationError as e:
result = {"sent": False, "message": f"SSLCertVerificationError: {str(e)}"}
self.results.append(result)

final_write_request = self.process_write_requests()
return (final_write_request, self.results)

def update_instance(self, instance: Dict[str, Any]) -> Dict[str, Any]:
user_id = instance["id"]
meeting_id = instance["meeting_id"]

result = self.get_initial_result_false(instance)
instance["result"] = result

user = self.datastore.get(
FullQualifiedId(Collection("user"), user_id),
[
"meeting_ids",
"email",
"username",
"last_name",
"first_name",
"title",
"default_password",
],
)
if not (to_email := user.get("email")):
result["message"] = f"User/{user_id} has no email-address."
return instance
if not self.check_email(to_email):
result[
"message"
] = f"The email-address {to_email} of User/{user_id} is not valid."
return instance
result["recipient"] = to_email

if meeting_id not in user["meeting_ids"]:
result[
"message"
] = f"User/{user_id} does not belong to meeting/{meeting_id}"
return instance

meeting = self.datastore.fetch_model(
FullQualifiedId(Collection("meeting"), meeting_id),
[
"name",
"users_email_sender",
"users_email_replyto",
"users_email_subject",
"users_email_body",
"users_pdf_url",
],
lock_result=False,
)

from_email: Union[str, Address]
if users_email_sender := meeting.get("users_email_sender", "").strip():
blacklist = ("[", "]", "\\")
if any(x in users_email_sender for x in blacklist):
result["message"] = (
f'Invalid characters in the sender name configuration of meeting_id "{meeting_id}". Not allowed chars: "'
+ '", "'.join(blacklist)
+ '"'
)
return instance
from_email = Address(
users_email_sender, addr_spec=EmailSettings.default_from_email
)
else:
from_email = EmailSettings.default_from_email

if (
reply_to := meeting.get("users_email_replyto", "")
) and not self.check_email(reply_to):
result["message"] = f"The given reply_to address '{reply_to}' is not valid."
return instance

class format_dict(defaultdict):
def __missing__(self, key: str) -> str:
return f"'{key}'"

subject_format = format_dict(
None,
{
"event_name": meeting.get("name", ""),
"name": get_user_name(user),
"username": user.get("username", ""),
},
)
body_format = format_dict(
None,
{
"url": meeting.get("users_pdf_url", ""),
"password": user.get("default_password", ""),
**subject_format,
},
)

self.send_email(
self.mail_client,
from_email,
to_email,
meeting.get("users_email_subject", "").format_map(subject_format),
meeting.get("users_email_body", "").format_map(body_format),
reply_to=reply_to,
html=False,
)
result["sent"] = True
instance["last_email_send"] = time()
return super().update_instance(instance)

def validate_instance(self, instance: Dict[str, Any]) -> None:
type(self).schema_validator(instance)

def get_initial_result_false(self, instance: Dict[str, Any]) -> Dict[str, Any]:
return {
"sent": False,
"recipient_user_id": instance.get("id"),
"recipient_meeting_id": instance.get("meeting_id"),
}

0 comments on commit 4d46523

Please sign in to comment.