def map(item):
        if item.deleted or item.suggestion_type != (
                feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT):
            return

        suggestion = suggestion_services.get_suggestion_from_model(item)

        exploration = exp_fetchers.get_exploration_by_id(suggestion.target_id)
        if suggestion.change.state_name not in exploration.states:
            return

        state = exploration.states[suggestion.change.state_name]
        subtitled_unicode_content_ids = []
        customisation_args = state.interaction.customization_args
        for ca_name in customisation_args:
            subtitled_unicode_content_ids.extend(
                state_domain.InteractionCustomizationArg.
                traverse_by_schema_and_get(
                    customisation_args[ca_name].schema,
                    customisation_args[ca_name].value,
                    [schema_utils.SCHEMA_OBJ_TYPE_SUBTITLED_UNICODE],
                    lambda subtitled_unicode: subtitled_unicode.content_id))
        if suggestion.change.content_id in subtitled_unicode_content_ids:
            if suggestion.change.cmd == exp_domain.CMD_ADD_WRITTEN_TRANSLATION:
                suggestion.change.data_format = (
                    schema_utils.SCHEMA_TYPE_UNICODE)
            suggestion.change.translation_html = html_cleaner.strip_html_tags(
                suggestion.change.translation_html)
            item.change_cmd = suggestion.change.to_dict()
            item.update_timestamps(update_last_updated_time=False)
            item.put()
            yield ('UPDATED',
                   '%s | %s' % (item.id, suggestion.change.content_id))
        yield ('PROCESSED', item.id)
Beispiel #2
0
    def test_strip_html_tags(self) -> None:
        test_data: List[Tuple[str, str]] = [
            (
                '<a href="http://www.google.com">Hello</a>',
                'Hello',
            ), (
                'Just some text 12345',
                'Just some text 12345',
            ), (
                '<code>Unfinished HTML',
                'Unfinished HTML',
            ), (
                '<br/>',
                '',
            ),
            (
                'A big mix <div>Hello</div> Yes <span>No</span>',
                'A big mix Hello Yes No',
            ), (
                'Text with\nnewlines',
                'Text with\nnewlines',
            )
        ]

        for datum in test_data:
            self.assertEqual(html_cleaner.strip_html_tags(datum[0]), datum[1])
Beispiel #3
0
def _get_plain_text_from_html_content_string(html_content_string):
    """Retrieves the plain text from the given html content string. RTE element
    occurrences in the html are replaced by their corresponding rte component
    name, capitalized in square brackets.
    eg: <p>Sample1 <oppia-noninteractive-math></oppia-noninteractive-math>
        Sample2 </p> will give as output: Sample1 [Math] Sample2.
    Note: similar logic exists in the frontend in format-rte-preview.filter.ts.

    Args:
        html_content_string: str. The content html string to convert to plain
            text.

    Returns:
        str. The plain text string from the given html content string.
    """

    def _replace_rte_tag(rte_tag):
        """Replaces all of the <oppia-noninteractive-**> tags with their
        corresponding rte component name in square brackets.

        Args:
            rte_tag: MatchObject. A matched object that contins the
                oppia-noninteractive rte tags.

        Returns:
            str. The string to replace the rte tags with.
        """
        # Retrieve the matched string from the MatchObject.
        rte_tag_string = rte_tag.group(0)
        # Get the name of the rte tag. The hyphen is there as an optional
        # matching character to cover the case where the name of the rte
        # component is more than one word.
        rte_tag_name = re.search(
            r'oppia-noninteractive-(\w|-)+', rte_tag_string)
        # Retrieve the matched string from the MatchObject.
        rte_tag_name_string = rte_tag_name.group(0)
        # Get the name of the rte component.
        rte_component_name_string = rte_tag_name_string.split('-')[2:]
        # If the component name is more than word, connect the words with spaces
        # to create a single string.
        rte_component_name_string = ' '.join(rte_component_name_string)
        # Captialize each word in the string.
        capitalized_rte_component_name_string = (
            rte_component_name_string.title())
        formatted_rte_component_name_string = ' [%s] ' % (
            capitalized_rte_component_name_string)
        return formatted_rte_component_name_string

    # Replace all the <oppia-noninteractive-**> tags with their rte component
    # names capitalized in square brackets.
    html_content_string_with_rte_tags_replaced = re.sub(
        r'<oppia-noninteractive-[^>]+>(.*?)</oppia-noninteractive-[^>]+>',
        _replace_rte_tag, html_content_string)
    # Get rid of all of the other html tags.
    plain_text = html_cleaner.strip_html_tags(
        html_content_string_with_rte_tags_replaced)
    # Remove trailing and leading whitespace and ensure that all words are
    # separated by a single space.
    plain_text_without_contiguous_whitespace = ' '.join(plain_text.split())
    return plain_text_without_contiguous_whitespace
Beispiel #4
0
def _send_bulk_mail(
    recipient_ids, sender_id, intent, email_subject, email_html_body, sender_email, sender_name, instance_id=None
):
    """Sends an email to all given recipients.

    Args:
        recipient_ids: list(str). The user IDs of the email recipients.
        sender_id: str. The ID of the user sending the email.
        intent: str. The intent string, i.e. the purpose of the email.
        email_subject: str. The subject of the email.
        email_html_body: str. The body (message) of the email.
        sender_email: str. The sender's email address.
        sender_name: str. The name to be shown in the "sender" field of the
            email.
        instance_id: str or None. The ID of the BulkEmailModel entity instance.
    """
    _require_sender_id_is_valid(intent, sender_id)

    recipients_settings = user_services.get_users_settings(recipient_ids)
    recipient_emails = [user.email for user in recipients_settings]

    cleaned_html_body = html_cleaner.clean(email_html_body)
    if cleaned_html_body != email_html_body:
        log_new_error(
            "Original email HTML body does not match cleaned HTML body:\n"
            "Original:\n%s\n\nCleaned:\n%s\n" % (email_html_body, cleaned_html_body)
        )
        return

    raw_plaintext_body = (
        cleaned_html_body.replace("<br/>", "\n")
        .replace("<br>", "\n")
        .replace("<li>", "<li>- ")
        .replace("</p><p>", "</p>\n<p>")
    )
    cleaned_plaintext_body = html_cleaner.strip_html_tags(raw_plaintext_body)

    def _send_bulk_mail_in_transaction(instance_id=None):
        sender_name_email = "%s <%s>" % (sender_name, sender_email)

        email_services.send_bulk_mail(
            sender_name_email, recipient_emails, email_subject, cleaned_plaintext_body, cleaned_html_body
        )

        if instance_id is None:
            instance_id = email_models.BulkEmailModel.get_new_id("")
        email_models.BulkEmailModel.create(
            instance_id,
            recipient_ids,
            sender_id,
            sender_name_email,
            intent,
            email_subject,
            cleaned_html_body,
            datetime.datetime.utcnow(),
        )

    return transaction_services.run_in_transaction(_send_bulk_mail_in_transaction, instance_id)
Beispiel #5
0
def _send_bulk_mail(recipient_ids,
                    sender_id,
                    intent,
                    email_subject,
                    email_html_body,
                    sender_email,
                    sender_name,
                    instance_id=None):
    """Sends an email to all given recipients.

    Args:
        recipient_ids: list(str). The user IDs of the email recipients.
        sender_id: str. The ID of the user sending the email.
        intent: str. The intent string, i.e. the purpose of the email.
        email_subject: str. The subject of the email.
        email_html_body: str. The body (message) of the email.
        sender_email: str. The sender's email address.
        sender_name: str. The name to be shown in the "sender" field of the
            email.
        instance_id: str or None. The ID of the BulkEmailModel entity instance.
    """
    _require_sender_id_is_valid(intent, sender_id)

    recipients_settings = user_services.get_users_settings(recipient_ids)
    recipient_emails = [user.email for user in recipients_settings]

    cleaned_html_body = html_cleaner.clean(email_html_body)
    if cleaned_html_body != email_html_body:
        log_new_error(
            'Original email HTML body does not match cleaned HTML body:\n'
            'Original:\n%s\n\nCleaned:\n%s\n' %
            (email_html_body, cleaned_html_body))
        return

    raw_plaintext_body = cleaned_html_body.replace('<br/>', '\n').replace(
        '<br>', '\n').replace('<li>',
                              '<li>- ').replace('</p><p>', '</p>\n<p>')
    cleaned_plaintext_body = html_cleaner.strip_html_tags(raw_plaintext_body)

    def _send_bulk_mail_in_transaction(instance_id=None):
        """Sends the emails in bulk to the recipients."""
        sender_name_email = '%s <%s>' % (sender_name, sender_email)

        email_services.send_bulk_mail(sender_name_email, recipient_emails,
                                      email_subject, cleaned_plaintext_body,
                                      cleaned_html_body)

        if instance_id is None:
            instance_id = email_models.BulkEmailModel.get_new_id('')
        email_models.BulkEmailModel.create(instance_id, recipient_ids,
                                           sender_id, sender_name_email,
                                           intent, email_subject,
                                           cleaned_html_body,
                                           datetime.datetime.utcnow())

    transaction_services.run_in_transaction(_send_bulk_mail_in_transaction,
                                            instance_id)
Beispiel #6
0
def _send_email(recipient_id,
                sender_id,
                intent,
                email_subject,
                email_html_body,
                sender_email,
                bcc_admin=False,
                sender_name=None):
    """Sends an email to the given recipient.

    This function should be used for sending all user-facing emails.

    Raises an Exception if the sender_id is not appropriate for the given
    intent. Currently we support only system-generated emails and emails
    initiated by moderator actions.
    """
    if sender_name is None:
        sender_name = EMAIL_SENDER_NAME.value

    _require_sender_id_is_valid(intent, sender_id)

    recipient_email = user_services.get_email_from_user_id(recipient_id)
    cleaned_html_body = html_cleaner.clean(email_html_body)
    if cleaned_html_body != email_html_body:
        log_new_error(
            'Original email HTML body does not match cleaned HTML body:\n'
            'Original:\n%s\n\nCleaned:\n%s\n' %
            (email_html_body, cleaned_html_body))
        return

    raw_plaintext_body = cleaned_html_body.replace('<br/>', '\n').replace(
        '<br>', '\n').replace('<li>',
                              '<li>- ').replace('</p><p>', '</p>\n<p>')
    cleaned_plaintext_body = html_cleaner.strip_html_tags(raw_plaintext_body)

    if email_models.SentEmailModel.check_duplicate_message(
            recipient_id, email_subject, cleaned_plaintext_body):
        log_new_error('Duplicate email:\n'
                      'Details:\n%s %s\n%s\n\n' %
                      (recipient_id, email_subject, cleaned_plaintext_body))
        return

    def _send_email_in_transaction():
        sender_name_email = '%s <%s>' % (sender_name, sender_email)

        email_services.send_mail(sender_name_email, recipient_email,
                                 email_subject, cleaned_plaintext_body,
                                 cleaned_html_body, bcc_admin)
        email_models.SentEmailModel.create(recipient_id, recipient_email,
                                           sender_id, sender_name_email,
                                           intent, email_subject,
                                           cleaned_html_body,
                                           datetime.datetime.utcnow())

    return transaction_services.run_in_transaction(_send_email_in_transaction)
    def map(item):
        """Implements the map function (generator). Computes word counts of
        translations suggestions and outputs suggestion metadata.

        Args:
            item: GeneralSuggestionModel. An instance of GeneralSuggestionModel.

        Yields:
            tuple(key, recent_activity_commits). Where:
                key: str. The entity ID of the corresponding
                    TranslationContributionStatsModel.
                dict. Has the keys:
                    suggestion_status: str. The translation suggestion status.
                    edited_by_reviewer: bool. Whether the translation suggestion
                        was edited by a reviewer.
                    content_word_count: int. The word count of the translation
                        suggestion content HTML.
                    last_updated_date: date. The last updated date of the
                        translation suggestion.
        """
        if item.suggestion_type != feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT:
            return

        suggestion = suggestion_services.get_suggestion_from_model(item)

        # Try to extract the topic ID from the corresponding exploration
        # opportunity.
        topic_id = ''
        exp_id = suggestion.target_id
        exp_opportunity_dict = (
            opportunity_services.get_exploration_opportunity_summaries_by_ids(
                [exp_id]))
        exp_opportunity = exp_opportunity_dict[exp_id]
        if exp_opportunity is not None:
            topic_id = exp_opportunity.topic_id

        # Count the number of words in the original content, ignoring any HTML
        # tags and attributes.
        content_plain_text = html_cleaner.strip_html_tags(
            suggestion.change.content_html)
        content_word_count = len(content_plain_text.split())

        key = suggestion_models.TranslationContributionStatsModel.generate_id(
            suggestion.language_code, suggestion.author_id, topic_id)
        translation_contribution_stats_dict = {
            'suggestion_status': suggestion.status,
            'edited_by_reviewer': suggestion.edited_by_reviewer,
            'content_word_count': content_word_count,
            'last_updated_date': suggestion.last_updated.date().isoformat()
        }
        yield (key, translation_contribution_stats_dict)
Beispiel #8
0
def generate_summary_of_blog_post(content):
    """Generates the summary for a blog post from the content of the blog
    post.

    Args:
        content: santized html str. The blog post content to be truncated.

    Returns:
        str. The summary of the blog post.
    """
    raw_text = html_cleaner.strip_html_tags(content)
    max_chars_in_summary = constants.MAX_CHARS_IN_BLOG_POST_SUMMARY - 3
    summary = raw_text[:max_chars_in_summary] + '...'
    return summary
Beispiel #9
0
def _send_email(
        recipient_id, sender_id, intent, email_subject, email_html_body,
        sender_email, bcc_admin=False, sender_name=None):
    """Sends an email to the given recipient.

    This function should be used for sending all user-facing emails.

    Raises an Exception if the sender_id is not appropriate for the given
    intent. Currently we support only system-generated emails and emails
    initiated by moderator actions.
    """
    if sender_name is None:
        sender_name = EMAIL_SENDER_NAME.value

    _require_sender_id_is_valid(intent, sender_id)

    recipient_email = user_services.get_email_from_user_id(recipient_id)
    cleaned_html_body = html_cleaner.clean(email_html_body)
    if cleaned_html_body != email_html_body:
        log_new_error(
            'Original email HTML body does not match cleaned HTML body:\n'
            'Original:\n%s\n\nCleaned:\n%s\n' %
            (email_html_body, cleaned_html_body))
        return

    raw_plaintext_body = cleaned_html_body.replace('<br/>', '\n').replace(
        '<br>', '\n').replace('<li>', '<li>- ').replace('</p><p>', '</p>\n<p>')
    cleaned_plaintext_body = html_cleaner.strip_html_tags(raw_plaintext_body)

    if email_models.SentEmailModel.check_duplicate_message(
            recipient_id, email_subject, cleaned_plaintext_body):
        log_new_error(
            'Duplicate email:\n'
            'Details:\n%s %s\n%s\n\n' %
            (recipient_id, email_subject, cleaned_plaintext_body))
        return

    def _send_email_in_transaction():
        sender_name_email = '%s <%s>' % (sender_name, sender_email)

        email_services.send_mail(
            sender_name_email, recipient_email, email_subject,
            cleaned_plaintext_body, cleaned_html_body, bcc_admin)
        email_models.SentEmailModel.create(
            recipient_id, recipient_email, sender_id, sender_name_email, intent,
            email_subject, cleaned_html_body, datetime.datetime.utcnow())

    return transaction_services.run_in_transaction(_send_email_in_transaction)
Beispiel #10
0
    def _generate_stats(
        suggestions: Iterable[suggestion_registry.SuggestionTranslateContent],
        opportunity: Optional[opportunity_domain.ExplorationOpportunitySummary]
    ) -> Iterable[Tuple[str, Dict[str, Union[bool, int, str]]]]:
        """Generates translation contribution stats for each suggestion.

        Args:
            suggestions: iter(SuggestionTranslateContent). Suggestions for which
                the stats should be generated.
            opportunity: ExplorationOpportunitySummary. Opportunity for which
                were the suggestions generated. Used to extract topic ID.

        Yields:
            tuple(str, Dict(str, *)). Tuple of key and suggestion stats dict.
            The stats dictionary has four fields:
                suggestion_status: str. What is the status of the suggestion.
                edited_by_reviewer: bool. Whether the suggestion was edited by
                    the reviewer.
                content_word_count: int. The word count of the content of
                    the suggestion.
                last_updated_date: str. When was the suggestion last updated.
        """
        # When opportunity is not available we leave the topic ID empty.
        topic_id = ''
        if opportunity is not None:
            topic_id = opportunity.topic_id

        for suggestion in suggestions:
            # Count the number of words in the original content, ignoring any
            # HTML tags and attributes.
            content_plain_text = html_cleaner.strip_html_tags(  # type: ignore[no-untyped-call]
                suggestion.change.content_html)  # type: ignore[attr-defined]
            content_word_count = len(content_plain_text.split())

            key = (suggestion_models.TranslationContributionStatsModel.
                   generate_id(suggestion.language_code, suggestion.author_id,
                               topic_id))
            translation_contribution_stats_dict = {
                'suggestion_status': suggestion.status,
                'edited_by_reviewer': suggestion.edited_by_reviewer,
                'content_word_count': content_word_count,
                'last_updated_date':
                suggestion.last_updated.date().isoformat()
            }
            yield (key, translation_contribution_stats_dict)
Beispiel #11
0
def _send_email(recipient_id, sender_id, intent, email_subject, email_html_body):
    """Sends an email to the given recipient.

    This function should be used for sending all user-facing emails.

    Raises an Exception if the sender_id is not appropriate for the given
    intent. Currently we support only system-generated emails and emails
    initiated by moderator actions.
    """
    _require_sender_id_is_valid(intent, sender_id)

    recipient_email = user_services.get_email_from_user_id(recipient_id)
    cleaned_html_body = html_cleaner.clean(email_html_body)
    if cleaned_html_body != email_html_body:
        log_new_error(
            "Original email HTML body does not match cleaned HTML body:\n"
            "Original:\n%s\n\nCleaned:\n%s\n" % (email_html_body, cleaned_html_body)
        )
        return

    raw_plaintext_body = cleaned_html_body.replace("<br/>", "\n").replace("<br>", "\n").replace("</p><p>", "</p>\n<p>")
    cleaned_plaintext_body = html_cleaner.strip_html_tags(raw_plaintext_body)

    def _send_email_in_transaction():
        sender_email = "%s <%s>" % (EMAIL_SENDER_NAME.value, feconf.SYSTEM_EMAIL_ADDRESS)
        email_services.send_mail(
            sender_email, recipient_email, email_subject, cleaned_plaintext_body, cleaned_html_body
        )
        email_models.SentEmailModel.create(
            recipient_id,
            recipient_email,
            sender_id,
            sender_email,
            intent,
            email_subject,
            cleaned_html_body,
            datetime.datetime.utcnow(),
        )

    return transaction_services.run_in_transaction(_send_email_in_transaction)
Beispiel #12
0
    def test_strip_html_tags(self):
        TEST_DATA = [(
            '<a href="http://www.google.com">Hello</a>',
            'Hello',
        ), (
            'Just some text 12345',
            'Just some text 12345',
        ), (
            '<code>Unfinished HTML',
            'Unfinished HTML',
        ), (
            '<br/>',
            '',
        ), (
            'A big mix <div>Hello</div> Yes <span>No</span>',
            'A big mix Hello Yes No',
        ), (
            'Text with\nnewlines',
            'Text with\nnewlines',
        )]

        for datum in TEST_DATA:
            self.assertEqual(html_cleaner.strip_html_tags(datum[0]), datum[1])
Beispiel #13
0
def _send_bulk_mail(
        recipient_ids, sender_id, intent, email_subject, email_html_body,
        sender_email, sender_name, instance_id=None):
    """Sends an email to all given recipients."""
    _require_sender_id_is_valid(intent, sender_id)

    recipients_settings = user_services.get_users_settings(recipient_ids)
    recipient_emails = [user.email for user in recipients_settings]

    cleaned_html_body = html_cleaner.clean(email_html_body)
    if cleaned_html_body != email_html_body:
        log_new_error(
            'Original email HTML body does not match cleaned HTML body:\n'
            'Original:\n%s\n\nCleaned:\n%s\n' %
            (email_html_body, cleaned_html_body))
        return

    raw_plaintext_body = cleaned_html_body.replace('<br/>', '\n').replace(
        '<br>', '\n').replace('<li>', '<li>- ').replace('</p><p>', '</p>\n<p>')
    cleaned_plaintext_body = html_cleaner.strip_html_tags(raw_plaintext_body)

    def _send_bulk_mail_in_transaction(instance_id=None):
        sender_name_email = '%s <%s>' % (sender_name, sender_email)

        email_services.send_bulk_mail(
            sender_name_email, recipient_emails, email_subject,
            cleaned_plaintext_body, cleaned_html_body)

        if instance_id is None:
            instance_id = email_models.BulkEmailModel.get_new_id('')
        email_models.BulkEmailModel.create(
            instance_id, recipient_ids, sender_id, sender_name_email, intent,
            email_subject, cleaned_html_body, datetime.datetime.utcnow())

    return transaction_services.run_in_transaction(
        _send_bulk_mail_in_transaction, instance_id)
Beispiel #14
0
def _send_email(recipient_id,
                sender_id,
                intent,
                email_subject,
                email_html_body,
                sender_email,
                bcc_admin=False,
                sender_name=None,
                reply_to_id=None):
    """Sends an email to the given recipient.

    This function should be used for sending all user-facing emails.

    Raises an Exception if the sender_id is not appropriate for the given
    intent. Currently we support only system-generated emails and emails
    initiated by moderator actions.

    Args:
        recipient_id: str. The user ID of the recipient.
        sender_id: str. The user ID of the sender.
        intent: str. The intent string for the email, i.e. the purpose/type.
        email_subject: str. The subject of the email.
        email_html_body: str. The body (message) of the email.
        sender_email: str. The sender's email address.
        bcc_admin: bool. Whether to send a copy of the email to the admin's
            email address.
        sender_name: str or None. The name to be shown in the "sender" field of
            the email.
        reply_to_id: str or None. The unique reply-to id used in reply-to email
            address sent to recipient.
    """

    if sender_name is None:
        sender_name = EMAIL_SENDER_NAME.value

    _require_sender_id_is_valid(intent, sender_id)

    recipient_email = user_services.get_email_from_user_id(recipient_id)
    cleaned_html_body = html_cleaner.clean(email_html_body)
    if cleaned_html_body != email_html_body:
        log_new_error(
            'Original email HTML body does not match cleaned HTML body:\n'
            'Original:\n%s\n\nCleaned:\n%s\n' %
            (email_html_body, cleaned_html_body))
        return

    raw_plaintext_body = cleaned_html_body.replace('<br/>', '\n').replace(
        '<br>', '\n').replace('<li>',
                              '<li>- ').replace('</p><p>', '</p>\n<p>')
    cleaned_plaintext_body = html_cleaner.strip_html_tags(raw_plaintext_body)

    if email_models.SentEmailModel.check_duplicate_message(
            recipient_id, email_subject, cleaned_plaintext_body):
        log_new_error('Duplicate email:\n'
                      'Details:\n%s %s\n%s\n\n' %
                      (recipient_id, email_subject, cleaned_plaintext_body))
        return

    def _send_email_in_transaction():
        """Sends the email to a single recipient."""
        sender_name_email = '%s <%s>' % (sender_name, sender_email)

        email_services.send_mail(sender_name_email,
                                 recipient_email,
                                 email_subject,
                                 cleaned_plaintext_body,
                                 cleaned_html_body,
                                 bcc_admin,
                                 reply_to_id=reply_to_id)
        email_models.SentEmailModel.create(recipient_id, recipient_email,
                                           sender_id, sender_name_email,
                                           intent, email_subject,
                                           cleaned_html_body,
                                           datetime.datetime.utcnow())

    transaction_services.run_in_transaction(_send_email_in_transaction)
Beispiel #15
0
def _send_email(
        recipient_id, sender_id, intent, email_subject, email_html_body,
        sender_email, bcc_admin=False, sender_name=None, reply_to_id=None):
    """Sends an email to the given recipient.

    This function should be used for sending all user-facing emails.

    Raises an Exception if the sender_id is not appropriate for the given
    intent. Currently we support only system-generated emails and emails
    initiated by moderator actions.

    Args:
        recipient_id: str. The user ID of the recipient.
        sender_id: str. The user ID of the sender.
        intent: str. The intent string for the email, i.e. the purpose/type.
        email_subject: str. The subject of the email.
        email_html_body: str. The body (message) of the email.
        sender_email: str. The sender's email address.
        bcc_admin: bool. Whether to send a copy of the email to the admin's
            email address.
        sender_name: str or None. The name to be shown in the "sender" field of
            the email.
        reply_to_id: str or None. The unique reply-to id used in reply-to email
            address sent to recipient.
    """

    if sender_name is None:
        sender_name = EMAIL_SENDER_NAME.value

    _require_sender_id_is_valid(intent, sender_id)

    recipient_email = user_services.get_email_from_user_id(recipient_id)
    cleaned_html_body = html_cleaner.clean(email_html_body)
    if cleaned_html_body != email_html_body:
        log_new_error(
            'Original email HTML body does not match cleaned HTML body:\n'
            'Original:\n%s\n\nCleaned:\n%s\n' %
            (email_html_body, cleaned_html_body))
        return

    raw_plaintext_body = cleaned_html_body.replace('<br/>', '\n').replace(
        '<br>', '\n').replace('<li>', '<li>- ').replace('</p><p>', '</p>\n<p>')
    cleaned_plaintext_body = html_cleaner.strip_html_tags(raw_plaintext_body)

    if email_models.SentEmailModel.check_duplicate_message(
            recipient_id, email_subject, cleaned_plaintext_body):
        log_new_error(
            'Duplicate email:\n'
            'Details:\n%s %s\n%s\n\n' %
            (recipient_id, email_subject, cleaned_plaintext_body))
        return

    def _send_email_in_transaction():
        sender_name_email = '%s <%s>' % (sender_name, sender_email)

        email_services.send_mail(
            sender_name_email, recipient_email, email_subject,
            cleaned_plaintext_body, cleaned_html_body, bcc_admin,
            reply_to_id=reply_to_id)
        email_models.SentEmailModel.create(
            recipient_id, recipient_email, sender_id, sender_name_email, intent,
            email_subject, cleaned_html_body, datetime.datetime.utcnow())

    return transaction_services.run_in_transaction(_send_email_in_transaction)
    def _generate_stats(
        suggestions: Iterable[suggestion_registry.SuggestionTranslateContent],
        opportunity: Optional[opportunity_domain.ExplorationOpportunitySummary]
    ) -> Tuple[str, result.Result[Dict[str, Union[bool, int, str]], str]]:
        """Generates translation contribution stats for each suggestion.

        Args:
            suggestions: iter(SuggestionTranslateContent). Suggestions for which
                the stats should be generated.
            opportunity: ExplorationOpportunitySummary. Opportunity for which
                were the suggestions generated. Used to extract topic ID.

        Yields:
            tuple(str, Dict(str, *)). Tuple of key and suggestion stats dict.
            The stats dictionary has four fields:
                suggestion_status: str. What is the status of the suggestion.
                edited_by_reviewer: bool. Whether the suggestion was edited by
                    the reviewer.
                content_word_count: int. The word count of the content of
                    the suggestion.
                last_updated_date: str. When was the suggestion last updated.
        """
        # When opportunity is not available we leave the topic ID empty.
        topic_id = ''
        if opportunity is not None:
            topic_id = opportunity.topic_id

        for suggestion in suggestions:
            key = (suggestion_models.TranslationContributionStatsModel.
                   generate_id(suggestion.language_code, suggestion.author_id,
                               topic_id))
            try:
                change = suggestion.change
                # In the new translation command the content in set format is
                # a list, content in unicode and html format is a string.
                # This code normalizes the content to the list type so that
                # we can easily count words.
                if (change.cmd == exp_domain.CMD_ADD_WRITTEN_TRANSLATION and
                        state_domain.WrittenTranslation.is_data_format_list(
                            change.data_format)):
                    content_items = change.content_html
                else:
                    content_items = [change.content_html]

                content_word_count = 0
                for item in content_items:
                    # Count the number of words in the original content,
                    # ignoring any HTML tags and attributes.
                    content_plain_text = html_cleaner.strip_html_tags(
                        item)  # type: ignore[no-untyped-call,attr-defined]
                    content_word_count += len(content_plain_text.split())

                translation_contribution_stats_dict = {
                    'suggestion_status':
                    suggestion.status,
                    'edited_by_reviewer':
                    suggestion.edited_by_reviewer,
                    'content_word_count':
                    content_word_count,
                    'last_updated_date':
                    (suggestion.last_updated.date().isoformat())
                }
                yield (key, result.Ok(translation_contribution_stats_dict))
            except Exception as e:
                yield (key,
                       result.Err('%s: %s' % (suggestion.suggestion_id, e)))