def notify_callback(): notify_data = get_json_from_request() if notify_data['status'] == 'permanent-failure': user = User.query.filter( User.email_address == notify_data['to'] ).first() if user and user.active: user.active = False db.session.add(user) audit_event = AuditEvent( audit_type=AuditTypes.update_user, user='******', data={"user": {"active": False}, "notify_callback_data": notify_data}, db_object=user, ) db.session.add(audit_event) db.session.commit() current_app.logger.info( "User account disabled for {hashed_email} after Notify reported permanent delivery " "failure.".format(hashed_email=hash_string(notify_data['to'])) ) elif notify_data['status'] == 'technical-failure': current_app.logger.warning("Notify failed to deliver {reference} to {hashed_email}".format( reference=notify_data['reference'], hashed_email=hash_string(notify_data['to']), )) return jsonify(status='ok'), 200
def process_login(): form = LoginForm() next_url = request.args.get('next') if form.validate_on_submit(): user_json = data_api_client.authenticate_user(form.email_address.data, form.password.data) if not user_json: current_app.logger.info( "login.fail: failed to log in {email_hash}", extra={'email_hash': hash_string(form.email_address.data)}) flash(NO_ACCOUNT_MESSAGE, "error") return render_template("auth/login.html", form=form, errors=get_errors_from_wtform(form), next=next_url), 403 user = User.from_json(user_json) login_user(user) current_app.logger.info("login.success: role={role} user={email_hash}", extra={ 'role': user.role, 'email_hash': hash_string(form.email_address.data) }) return redirect_logged_in_user(next_url) else: errors = get_errors_from_wtform(form) return render_template("auth/login.html", form=form, errors=errors, next=next_url), 400
def send_email(self, email_address, template_id, personalisation=None, allow_resend=True, reference=None): """ Method to send an email using the Notify api. :param email_address: String email address for recipient :param template_id: Template accessible on the Notify account whose api_key you instantiated the class with :param personalisation: The template variables, dict :param allow_resend: if False instantiate the delivered reference cache and ensure we are not sending duplicates :return: response from the api. For more information see https://github.com/alphagov/notifications-python-client """ reference = reference or self.get_reference(email_address, template_id, personalisation) if not allow_resend and self.has_been_sent(reference): self.logger.info( "Email {reference} (template {template_id}) has already been sent to {email_address} through Notify", extra=dict( email_address=hash_string(email_address), template_id=template_id, reference=reference, ), ) return try: response = self.client.send_email_notification( email_address, template_id, personalisation=personalisation, reference=reference, ) except HTTPError as e: self.logger.error( self.get_error_message(hash_string(email_address), e)) raise EmailError(str(e)) self._update_cache(reference) self.logger.info( "Sent email {reference} to {email_address} (id: {notify_id}, template: {template_id}) through Notify", extra=dict( email_address=hash_string(email_address), notify_id=response['id'], template_id=template_id, reference=reference, ), ) return response
def test_main_catches_email_errors_and_returns_ids(data_api_client, notify_client): logger = mock.Mock() notify_client.send_email.side_effect = [EmailError, True] data_api_client.find_brief_responses_iter.side_effect = [ [_get_dummy_brief_response(4321, AWARDED_BRIEFS[0], awarded=True)], [_get_dummy_brief_response(1234, AWARDED_BRIEFS[0], awarded=False)], ] assert not tested_script.main(data_api_client, notify_client, 'notify_template_id', 'preview', logger) assert logger.error.call_args_list == [ mock.call( 'Email sending failed for BriefResponse {brief_response_id} (Brief ID {brief_id})', extra={ "brief_id": 123, "brief_response_id": 4321 }), mock.call( "Email sending failed for the following {count} BriefResponses: {brief_response_ids}", extra={ 'brief_response_ids': '4321', 'count': 1 }) ] assert logger.info.call_args_list == [ mock.call( "{dry_run}EMAIL: Award of Brief Response ID: {brief_response_id} to {email_address}", extra={ 'dry_run': '', 'brief_response_id': 1234, 'email_address': hash_string('*****@*****.**'), }) ]
def send_email(self, to_email_address, template_name_or_id, personalisation=None, allow_resend=True, reference=None, reply_to_address_id=None): """ Method to send an email using the Notify api. :param to_email_address: String email address for recipient :param template_name_or_id: Template accessible on the Notify account, can either be a key to the `templates` dictionary or a Notify template ID. :param personalisation: The template variables, dict :param allow_resend: if False instantiate the delivered reference cache and ensure we are not sending duplicates :param reply_to_address_id: String id of reply-to email address. Must be set up in Notify config before use :return: response from the api. For more information see https://github.com/alphagov/notifications-python-client """ template_id = self.templates.get(template_name_or_id, template_name_or_id) reference = reference or self.get_reference( to_email_address, template_id, personalisation) if not allow_resend and self.has_been_sent(reference): self.logger.info( "Email with reference '{reference}' has already been sent", extra=dict(client=self.client.__class__, to_email_address=hash_string(to_email_address), template_name_or_id=template_name_or_id, reference=reference, reply_to_address_id=reply_to_address_id), ) return # NOTE how the potential replacement of the email address happens *after* the has_been_sent check and # reference generation final_email_address = ( self._redirect_domains_to_address and self._redirect_domains_to_address.get( # splitting at rightmost @ should reliably give us the domain to_email_address.rsplit("@", 1) [-1].lower())) or to_email_address try: with log_external_request(service='Notify'): response = self.client.send_email_notification( final_email_address, template_id, personalisation=personalisation, reference=reference, email_reply_to_id=reply_to_address_id) except HTTPError as e: self._log_email_error_message(to_email_address, template_name_or_id, reference, e) raise EmailError(str(e)) self._update_cache(reference) return response
def generate_token(json_data, secret_key, namespace): """ Encrypt data using a provided secret_key and namespace. The process is as follows: * The secret_key is combined with the namespace and hashed using sha256. The namespace ensures that a token for `invite-user` cannot be re-used by an attacker on the `reset-password` endpoint. Hashing is used to ensure the key conforms to the 32 byte length requirement for Fernet. * The combined secret key is used to initialise the Fernet encryption algorithm. Fernet is a wrapper around AES that provides HMAC, TTL (time to live), and some quality of life features (url-safe base64 encoding) The fernet spec can be viewed here: https://github.com/fernet/spec/blob/master/Spec.md * The data is dumped from json and encrypted. * The output data is returned as a urlsafe_base64 (https://tools.ietf.org/html/rfc4648#section-5) unicode string. Fernet acepts and returns bytes, so call `.encode` before and `.decode` after to convert to strings, to ensure we use regular python strings for as much of the code flow as possible :param json_data: data to encrypt. Must be json-like blob that `json.dumps` will accept :param secret_key: The secret key to encrypt with. No length/content restrictions. Must be a string type. :param namespace: The namespace to encrypt with. No length/content restrictions. Must be a string type. :return: returns a urlsale_base64 encoded encrypted unicode string. :rtype: `unicode` """ secret_key = hash_string(secret_key + namespace) data = json.dumps(json_data).encode('utf-8') f = fernet.Fernet(secret_key.encode('utf-8')) return f.encrypt(data).decode('utf-8')
def process_login(): form = LoginForm() next_url = request.args.get('next') if form.validate_on_submit(): user_json = data_api_client.authenticate_user(form.email_address.data, form.password.data) if not user_json: current_app.logger.info( "login.fail: failed to log in {email_hash}", extra={'email_hash': hash_string(form.email_address.data)}) errors = govuk_errors({ "email_address": { "message": "Enter your email address", "input_name": "email_address", }, "password": { "message": "Enter your password", "input_name": "password", }, }) return render_template( "auth/login.html", form=form, errors=errors, error_summary_description_text=NO_ACCOUNT_MESSAGE, next=next_url), 403 user = User.from_json(user_json) login_user(user) current_app.logger.info("login.success: role={role} user={email_hash}", extra={ 'role': user.role, 'email_hash': hash_string(form.email_address.data) }) return redirect_logged_in_user(next_url) else: errors = get_errors_from_wtform(form) return render_template("auth/login.html", form=form, errors=errors, next=next_url), 400
def __call__(self, form, field): user_json = data_api_client.authenticate_user( current_user.email_address, field.data) if user_json is None: current_app.logger.info( "change_password.fail: failed to authenticate user {email_hash}", extra={'email_hash': hash_string(current_user.email_address)}) raise ValidationError(self.message)
def get_reference(to_email_address, template_id, personalisation=None): """ Method to return the standard reference given the variables the email is sent with. :param to_email_address: Emails recipient :param template_id: Emails template ID on Notify :param personalisation: Template parameters :return: Hashed string 'reference' to be passed to client.send_email_notification or self.send_email """ personalisation_string = ",".join(map( str, personalisation.values())) if personalisation else "" return hash_string( f"{to_email_address}|{template_id}|{personalisation_string}")
def notify_callback(): notify_data = get_json_from_request() if notify_data['status'] == 'permanent-failure': user = User.query.filter( User.email_address == notify_data['to']).first() if user and user.active: user.active = False db.session.add(user) audit_event = AuditEvent( audit_type=AuditTypes.update_user, user='******', data={ "user": { "active": False }, "notify_callback_data": notify_data }, db_object=user, ) db.session.add(audit_event) db.session.commit() current_app.logger.info( "User account disabled for {hashed_email} after Notify reported permanent delivery " "failure.".format(hashed_email=hash_string(notify_data['to']))) elif notify_data['status'] == 'technical-failure': current_app.logger.warning( "Notify failed to deliver {reference} to {hashed_email}".format( reference=notify_data['reference'], hashed_email=hash_string(notify_data['to']), )) return jsonify(status='ok'), 200
def notify_callback(): notify_data = get_json_from_request() email_address = notify_data["to"] hashed_email = hash_string(email_address) reference = notify_data["reference"] status = notify_data["status"] # remove PII from response for logging # according to docs only "to" has PII # https://docs.notifications.service.gov.uk/rest-api.html#delivery-receipts clean_notify_data = notify_data.copy() del clean_notify_data["to"] current_app.logger.info( f"Notify callback: {status}: {reference} to {hashed_email}", extra={"notify_delivery_receipt": clean_notify_data}, ) if status == "permanent-failure": user = User.query.filter(User.email_address == email_address).first() if user and user.active: user.active = False db.session.add(user) audit_event = AuditEvent( audit_type=AuditTypes.update_user, user='******', data={ "user": { "active": False }, "notify_callback_data": notify_data }, db_object=user, ) db.session.add(audit_event) db.session.commit() current_app.logger.info( f"User account disabled for {hashed_email} after Notify reported permanent delivery " "failure.") elif status.endswith("failure"): current_app.logger.warning( f"Notify failed to deliver {reference} to {hashed_email}") return jsonify(status='ok'), 200
def log_email_error(exception, email_type, error_code, email_address): """ Log errors in a separate module so we can patch `current_app.logger` in tests and assert the calls, without affecting the test client. """ current_app.logger.error( "{code}: {email_type} email for email_hash {email_hash} failed to send. " "Error: {error}", extra={ 'email_hash': hash_string(email_address), 'error': str(exception), 'code': '{}'.format(error_code), 'email_type': email_type })
def get_reference(to_email_address, template_id, personalisation=None): """ Method to return the standard reference given the variables the email is sent with. :param to_email_address: Emails recipient :param template_id: Emails template ID on Notify :param personalisation: Template parameters :return: Hashed string 'reference' to be passed to client.send_email_notification or self.send_email """ personalisation_string = u','.join( list(map(lambda x: u'{}'.format(x), personalisation.values()))) if personalisation else u'' details_string = u'|'.join( [to_email_address, template_id, personalisation_string]) return hash_string(details_string)
def _log(self, lvl, msg, email_obj: DMNotifyEmail, *, extra: Optional[dict] = None, **kwargs): if extra is None: extra = {} extra.update({ "client": self.__class__, "reference": email_obj.reference, "template_name_or_id": email_obj.template_name_or_id, "to_email_address": hash_string(email_obj.to_email_address), }) self.logger.log(lvl, msg, extra)
def _log_email_error_message(self, to_email_address, template_name_or_id, reference, error): """Format a logical error message from the error response and send it to the logger""" error_messages = [] for error_message in error.message: error_messages.append("{status_code} {error}: {message}".format( status_code=error.status_code, error=error_message["error"], message=error_message["message"], )) self.logger.error( "Error sending email: {error_messages}", extra={ "client": self.__class__, "reference": reference, "template_name_or_id": template_name_or_id, "to_email_address": hash_string(to_email_address), "error_messages": error_messages, }, )
def main(data_api_client, mail_client, template_id, stage, logger, withdrawn_date, brief_id=None, dry_run=False): withdrawn_briefs = data_api_client.find_briefs_iter( status='withdrawn', withdrawn_on=withdrawn_date) if brief_id: withdrawn_briefs = filter(lambda i: i['id'] == brief_id, withdrawn_briefs) for brief in withdrawn_briefs: email_addresses = get_brief_response_emails(data_api_client, brief['id']) if not email_addresses: continue brief_email_context = create_context_for_brief(stage, brief) for email_address in email_addresses: if not dry_run: mail_client.send_email(email_address, template_id, brief_email_context, allow_resend=False) logger.info( "%sEMAIL: Withdrawal of Brief ID: %d to %s", '[Dry-run]' if dry_run else '', brief['id'], hash_string(email_address), ) return True
def decode_token(encrypted_data, secret_key, namespace, max_age_in_seconds=ONE_DAY_IN_SECONDS): """ Decrypt data using a provided secret_key, namespace, and TTL (max_age_in_seconds). The process is as follows: * secret key and fernet are initialised as in `encrypt_data` * the data is decrypted and json_dumped * the timestamp is pulled out and compared to the max_age_in_seconds to ensure that the token has not expired * the original json-able blob is returned, along with the timestamp that it was encrypted at. This timestamp can then be used for verifying the authenticity of the request, for example, comparing a password reset token against the last time the password was reset to ensure it is not used twice. Fernet acepts and returns bytes, so call `.encode` before and `.decode` after to convert to strings, to ensure we use regular python strings for as much of the code flow as possible :param encrypted_data: data to decrypt. Must be a string type. :param secret_key: The secret key you encrypted the data with. Must be a string type. :param namespace: The namespace you encrypted the data with. Must be a string type. :param max_age_in_seconds: The maximum age of the encrypted data. :return: the original encrypted data and the datetime it was encrypted at. :rtype: `tuple(json-able, datetime)` :raises fernet.InvalidToken: If the secret key and namespace are not able to decrypt the message, or max_age_in_seconds has been exceeded. """ encrypted_bytes = encrypted_data.encode('utf-8') secret_key = hash_string(secret_key + namespace) f = fernet.Fernet(secret_key.encode('utf-8')) # this raises fernet.InvalidToken if the key does not match or if TTL is exceeded data = f.decrypt(encrypted_bytes, ttl=max_age_in_seconds) timestamp = _parse_fernet_timestamp(encrypted_bytes) return json.loads(data.decode('utf-8')), timestamp
def _build_and_send_emails(brief_responses, mail_client, stage, dry_run, template_id, logger): failed_brief_responses = [] # Now email everyone whose Brief got awarded for brief_response in brief_responses: # Although we don't officially support it, in some cases users # have multiple email addresses in their respondToEmailAddress field email_addresses = get_email_addresses( brief_response["respondToEmailAddress"]) invalid_email_addresses = [ a for a in email_addresses if not validate_email_address(a) ] if invalid_email_addresses: logger.error( "Invalid email address(es) for BriefResponse {brief_response_id} (Brief ID {brief_id})", extra={ "brief_id": brief_response['brief']['id'], "brief_response_id": brief_response['id'], }) failed_brief_responses.append(brief_response['id']) for invalid_email_address in invalid_email_addresses: email_addresses.remove(invalid_email_address) if not email_addresses: logger.error( "No valid email address(es) for BriefResponse {brief_response_id} (Brief ID {brief_id})", extra={ "brief_id": brief_response['brief']['id'], "brief_response_id": brief_response['id'], }) continue brief_email_context = _create_context_for_brief( stage, brief_response['brief']) for email_address in email_addresses: try: if not dry_run: mail_client.send_email(email_address, template_id, brief_email_context, allow_resend=False) logger.info( "{dry_run}EMAIL: Award of Brief Response ID: {brief_response_id} to {email_address}", extra={ 'dry_run': '[Dry-run] - ' if dry_run else '', 'brief_response_id': brief_response['id'], 'email_address': hash_string(email_address), }) except EmailError as e: # Log individual failures in more detail logger.error( "Email sending failed for BriefResponse {brief_response_id} (Brief ID {brief_id})", extra={ "brief_id": brief_response['brief']['id'], "brief_response_id": brief_response['id'] }) if isinstance(e, EmailTemplateError): raise # do not try to continue failed_brief_responses.append(brief_response['id']) return failed_brief_responses
def send_brief_clarification_question(data_api_client, brief, clarification_question): questions_url = ( get_web_url_from_stage(current_app.config["DM_ENVIRONMENT"]) + url_for('external.supplier_questions', framework_slug=brief["framework"]['slug'], lot_slug=brief["lotSlug"], brief_id=brief["id"])) notify_client = DMNotifyClient(current_app.config['DM_NOTIFY_API_KEY']) # Email the question to brief owners for email_address in get_brief_user_emails(brief): try: notify_client.send_email( email_address, template_name_or_id=current_app.config['NOTIFY_TEMPLATES'] ['clarification_question'], personalisation={ "brief_title": brief['title'], "brief_name": brief['title'], "message": escape(clarification_question), "publish_by_date": dateformat(brief['clarificationQuestionsPublishedBy']), "questions_url": questions_url }, reference="clarification-question-{}".format( hash_string(email_address))) except EmailError as e: current_app.logger.error( "Brief question email failed to send. error={error} supplier_id={supplier_id} brief_id={brief_id}", extra={ 'error': six.text_type(e), 'supplier_id': current_user.supplier_id, 'brief_id': brief['id'] }) abort(503, "Clarification question email failed to send") data_api_client.create_audit_event( audit_type=AuditTypes.send_clarification_question, user=current_user.email_address, object_type="briefs", object_id=brief['id'], data={ "question": clarification_question, "briefId": brief['id'], "supplierId": current_user.supplier_id }) brief_url = (get_web_url_from_stage(current_app.config["DM_ENVIRONMENT"]) + url_for('external.get_brief_by_id', framework_family=brief['framework']['family'], brief_id=brief['id'])) # Send the supplier a copy of the question try: notify_client.send_email( current_user.email_address, template_name_or_id=current_app.config["NOTIFY_TEMPLATES"] ["clarification_question_confirmation"], personalisation={ "brief_name": brief['title'], "message": escape(clarification_question), "brief_url": brief_url, }, reference="clarification-question-confirmation-{}".format( hash_string(current_user.email_address))) except EmailError as e: current_app.logger.error( "Brief question supplier email failed to send. error={error} supplier_id={supplier_id} brief_id={brief_id}", extra={ 'error': six.text_type(e), 'supplier_id': current_user.supplier_id, 'brief_id': brief['id'] })
def test_hash_string(self, test, expected): expected = expected.decode('utf-8') result = hash_string(test) assert result == expected
def send_email(to_email_addresses, email_body, api_key, subject, from_email, from_name, tags, reply_to=None, metadata=None, logger=None): logger = logger or current_app.logger if isinstance(to_email_addresses, string_types): to_email_addresses = [to_email_addresses] try: mandrill_client = Mandrill(api_key) message = { 'html': email_body, 'subject': subject, 'from_email': from_email, 'from_name': from_name, 'to': [{ 'email': email_address, 'type': 'to' } for email_address in to_email_addresses], 'important': False, 'track_opens': False, 'track_clicks': False, 'auto_text': True, 'tags': tags, 'metadata': metadata, 'headers': { 'Reply-To': reply_to or from_email }, 'preserve_recipients': False, 'recipient_metadata': [{ 'rcpt': email_address } for email_address in to_email_addresses] } result = mandrill_client.messages.send(message=message, async=True) except Error as e: # Mandrill errors are thrown as exceptions logger.error("Failed to send an email: {error}", extra={'error': e}) raise EmailError(e) logger.info("Sent {tags} response: id={id}, email={email_hash}", extra={ 'tags': tags, 'id': result[0]['_id'], 'email_hash': hash_string(result[0]['email']) })
def change_password(): form = PasswordChangeForm() dashboard_url = get_user_dashboard_url(current_user) # Checking that the old password is correct is done as a validator on the form. if form.validate_on_submit(): response = data_api_client.update_user_password( current_user.id, form.password.data, updater=current_user.email_address) if response: current_app.logger.info( "User {user_id} successfully changed their password", extra={'user_id': current_user.id}) notify_client = DMNotifyClient( current_app.config['DM_NOTIFY_API_KEY']) token = generate_token( {"user": current_user.id}, current_app.config['SHARED_EMAIL_KEY'], current_app.config['RESET_PASSWORD_TOKEN_NS']) try: notify_client.send_email( current_user.email_address, template_name_or_id=current_app.config['NOTIFY_TEMPLATES'] ['change_password_alert'], personalisation={ 'url': url_for('main.reset_password', token=token, _external=True), }, reference='change-password-alert-{}'.format( hash_string(current_user.email_address))) current_app.logger.info( "{code}: Password change alert email sent for email_hash {email_hash}", extra={ 'email_hash': hash_string(current_user.email_address), 'code': 'login.password-change-alert-email.sent' }) except EmailError as exc: log_email_error( exc, "Password change alert", "login.password-change-alert-email.notify-error", current_user.email_address) flash(PASSWORD_UPDATED_MESSAGE, "success") else: flash(PASSWORD_NOT_UPDATED_MESSAGE, "error") return redirect(dashboard_url) errors = get_errors_from_wtform(form) return render_template( "auth/change-password.html", form=form, errors=errors, dashboard_url=dashboard_url), 200 if not errors else 400
def update_user(user_id): """ Update a user. Looks user up in DB, and updates where necessary. """ update_details = validate_and_return_updater_request() user = User.query.filter(User.id == user_id).first_or_404() json_payload = get_json_from_request() json_has_required_keys(json_payload, ["users"]) user_update = json_payload["users"] json_has_matching_id(user_update, user_id) if 'password' in user_update: user.password = encryption.hashpw(user_update['password']) user.password_changed_at = datetime.utcnow() user.failed_login_count = 0 user_update['password'] = '******' if user.role in ('admin-manager', ): current_app.logger.warning( "{code}: Password reset requested for {user_role} user '{email_hash}'", extra={ "code": "update_user.password.role_warning", "email_hash": hash_string(user.email_address), "user_role": user.role, }) if 'active' in user_update: user.active = user_update['active'] if 'name' in user_update: user.name = user_update['name'] if 'emailAddress' in user_update: user.email_address = user_update['emailAddress'] if 'phoneNumber' in user_update: user.phone_number = user_update['phoneNumber'] if 'role' in user_update: if user.role == 'supplier' and user_update['role'] != user.role: user.supplier_id = None user_update.pop('supplierId', None) if user.role == 'buyer' and user_update['role'] != user.role: abort(400, "Can not change a 'buyer' user to a different role.") user.role = user_update['role'] if 'supplierId' in user_update: user.supplier_id = user_update['supplierId'] if 'locked' in user_update and not user_update['locked']: user.failed_login_count = 0 if 'userResearchOptedIn' in user_update: user.user_research_opted_in = user_update['userResearchOptedIn'] check_supplier_role(user.role, user.supplier_id) audit = AuditEvent(audit_type=AuditTypes.update_user, user=update_details.get('updated_by', 'no user data'), data={ 'user': user.email_address, 'update': user_update }, db_object=user) db.session.add(user) db.session.add(audit) try: db.session.commit() return single_result_response(RESOURCE_NAME, user), 200 except (IntegrityError, DataError): db.session.rollback() abort(400, "Could not update user with: {0}".format(user_update))
def _build_and_send_emails(brief_responses, mail_client, stage, dry_run, template_id, logger): failed_brief_responses = [] # Now email everyone whose Brief got awarded for brief_response in brief_responses: email_addresses = get_email_addresses(brief_response["respondToEmailAddress"]) # Users can enter multiple email addresses, but our input validation is very basic. So it's OK if some of the # addresses are invalid, as long as there is at least one valid address. invalid_email_addresses = [a for a in email_addresses if not validate_email_address(a)] if invalid_email_addresses: logger.warning( "Invalid email address(es) for BriefResponse {brief_response_id} (Brief ID {brief_id})", extra={ "brief_id": brief_response['brief']['id'], "brief_response_id": brief_response['id'], } ) for invalid_email_address in invalid_email_addresses: email_addresses.remove(invalid_email_address) if not email_addresses: logger.error( "No valid email address(es) for BriefResponse {brief_response_id} (Brief ID {brief_id})", extra={ "brief_id": brief_response['brief']['id'], "brief_response_id": brief_response['id'], } ) continue brief_email_context = _create_context_for_brief(stage, brief_response['brief']) for email_address in email_addresses: try: if not dry_run: mail_client.send_email( email_address, template_id, brief_email_context, allow_resend=False ) logger.info( "{dry_run}EMAIL: Award of Brief Response ID: {brief_response_id} to {email_address}", extra={ 'dry_run': '[Dry-run] - ' if dry_run else '', 'brief_response_id': brief_response['id'], 'email_address': hash_string(email_address), } ) except EmailError as e: # Log individual failures in more detail logger.error( "Email sending failed for BriefResponse {brief_response_id} (Brief ID {brief_id})", extra={ "brief_id": brief_response['brief']['id'], "brief_response_id": brief_response['id'] } ) if isinstance(e, EmailTemplateError): raise # do not try to continue failed_brief_responses.append(brief_response['id']) return failed_brief_responses
def send_reset_password_email(): form = EmailAddressForm() if form.validate_on_submit(): email_address = form.email_address.data user_json = data_api_client.get_user(email_address=email_address) notify_client = DMNotifyClient(current_app.config['DM_NOTIFY_API_KEY']) if user_json is not None: user = User.from_json(user_json) if user.role in ("admin-manager", ): # if this user wants their password reset they'll have to come to us current_app.logger.warning( "{code}: Password reset requested for {user_role} user '{email_hash}'", extra={ "code": "login.reset-email.bad-role", "email_hash": hash_string(user.email_address), "user_role": user.role, }) elif user.active: # specifically checking just .active, ignoring whether account is "locked" token = generate_token( {"user": user.id}, current_app.config['SHARED_EMAIL_KEY'], current_app.config['RESET_PASSWORD_TOKEN_NS']) try: notify_client.send_email( user.email_address, template_name_or_id=current_app. config['NOTIFY_TEMPLATES']['reset_password'], personalisation={ 'url': url_for('main.reset_password', token=token, _external=True), }, reference='reset-password-{}'.format( hash_string(user.email_address)), ) except EmailError as exc: log_email_error( exc, "Password reset", "login.reset-email.notify-error", user.email_address, ) abort(503, "Failed to send password reset email.") current_app.logger.info( "{code}: Sent password reset email for email_hash {email_hash}", extra={ 'email_hash': hash_string(user.email_address), 'code': 'login.reset-email.sent' }) else: try: notify_client.send_email( user.email_address, template_name_or_id=current_app. config['NOTIFY_TEMPLATES']['reset_password_inactive'], reference='reset-password-inactive-{}'.format( hash_string(user.email_address)), ) except EmailError as exc: log_email_error( exc, "Password reset (inactive user)", "login.reset-email-inactive.notify-error", user.email_address, ) abort(503, "Failed to send password reset email.") current_app.logger.warning( "{code}: Sent password (non-)reset email for inactive user email_hash {email_hash}", extra={ 'email_hash': hash_string(user.email_address), 'code': 'login.reset-email-inactive.sent', }) else: # Send a email to the Notify sandbox using the 'inactive' template, to mitigate any timing attacks (where # a user's existence could be determined by the response time of this view). Any errors are also handled # in the same way as for inactive users. try: notify_client.send_email( NOTIFY_SANDBOX_ADDRESS, template_name_or_id=current_app.config['NOTIFY_TEMPLATES'] ['reset_password_inactive'], reference='reset-password-nonexistent-user-{}'.format( hash_string(email_address)), ) except EmailError as exc: log_email_error( exc, "Password reset (non-existent user)", "login.reset-email-nonexistent.notify-error", email_address, # Hashed by the helper function ) abort(503, "Failed to send password reset email.") current_app.logger.info( "{code}: Sent password (non-)reset email for invalid user email_hash {email_hash}", extra={ 'email_hash': hash_string(email_address), 'code': 'login.reset-email.invalid-email' }) flash( EMAIL_SENT_MESSAGE.format( support_email=current_app.config['SUPPORT_EMAIL_ADDRESS']), "success") return redirect(url_for('.request_password_reset')) else: return render_template("auth/request-password-reset.html", errors=get_errors_from_wtform(form), form=form), 400
def notify_suppliers_of_framework_application_event( data_api_client: DataAPIClient, notify_client: DMNotifyClient, notify_template_id: str, framework_slug: str, stage: str, dry_run: bool, logger: Logger, run_id: Optional[UUID] = None, ) -> int: run_is_new = not run_id run_id = run_id or uuid4() logger.info( f"{'Starting' if run_is_new else 'Resuming'} run id {{run_id}}", extra={"run_id": str(run_id)}) framework = data_api_client.get_framework(framework_slug)["frameworks"] framework_context = { "framework_name": framework["name"], "updates_url": f"{get_web_url_from_stage(stage)}/suppliers/frameworks/{framework['slug']}/updates", "framework_dashboard_url": f"{get_web_url_from_stage(stage)}/suppliers/frameworks/{framework['slug']}/", "clarification_questions_closed": "no" if framework["clarificationQuestionsOpen"] else "yes", **_formatted_dates_from_framework(framework), } failure_count = 0 for supplier_framework in data_api_client.find_framework_suppliers_iter( framework_slug): for user in data_api_client.find_users_iter( supplier_id=supplier_framework["supplierId"]): if user["active"]: # generating ref separately so we can exclude certain parameters from the context dict notify_ref = notify_client.get_reference( user["emailAddress"], notify_template_id, { "framework_slug": framework["slug"], "run_id": str(run_id), }, ) if dry_run: # Use the sent references cache unless we're re-running the script following a failure if notify_client.has_been_sent( notify_ref, use_recent_cache=run_is_new): logger.debug( "[DRY RUN] Would NOT send notification to {email_hash} (already sent)", extra={ "email_hash": hash_string(user["emailAddress"]) }, ) else: logger.info( "[DRY RUN] Would send notification to {email_hash}", extra={ "email_hash": hash_string(user["emailAddress"]) }, ) else: try: # Use the sent references cache unless we're re-running the script following a failure notify_client.send_email( user["emailAddress"], notify_template_id, framework_context, allow_resend=False, reference=notify_ref, use_recent_cache=run_is_new, ) except EmailError as e: failure_count += 1 logger.error( "Failed sending to {email_hash}: {e}", extra={ "email_hash": hash_string(user["emailAddress"]), "e": str(e), }, ) if isinstance(e, EmailTemplateError): raise # do not try to continue return failure_count