diff --git a/app/app/api/views/alias.py b/app/app/api/views/alias.py index 45297e6..7630b4b 100644 --- a/app/app/api/views/alias.py +++ b/app/app/api/views/alias.py @@ -419,9 +419,8 @@ def create_contact_route(alias_id): if not data: return jsonify(error="request body cannot be empty"), 400 - alias: Alias = Alias.get(alias_id) - - if alias.user_id != g.user.id: + alias: Optional[Alias] = Alias.get_by(id=alias_id, user_id=g.user.id) + if not alias: return jsonify(error="Forbidden"), 403 contact_address = data.get("contact") diff --git a/app/app/config.py b/app/app/config.py index 97b1e0a..9ec2cb6 100644 --- a/app/app/config.py +++ b/app/app/config.py @@ -309,6 +309,7 @@ JOB_DELETE_DOMAIN = "delete-domain" JOB_SEND_USER_REPORT = "send-user-report" JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1" JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events" +JOB_SEND_EVENT_TO_WEBHOOK = "send-event-to-webhook" # for pagination PAGE_LIMIT = 20 diff --git a/app/app/contact_utils.py b/app/app/contact_utils.py index 4d508a1..e2bb62e 100644 --- a/app/app/contact_utils.py +++ b/app/app/contact_utils.py @@ -16,6 +16,7 @@ from app.utils import sanitize_email class ContactCreateError(Enum): InvalidEmail = "Invalid email" NotAllowed = "Your plan does not allow to create contacts" + Unknown = "Unknown error when trying to create contact" @dataclass @@ -87,6 +88,7 @@ def create_contact( return __update_contact_if_needed(contact, name, mail_from) # Create the contact reply_email = generate_reply_email(email, alias) + alias_id = alias.id try: flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0 contact = Contact.create( @@ -114,11 +116,21 @@ def create_contact( LOG.d( f"Created contact {contact} for alias {alias} with email {email} invalid_email={contact.invalid_email}" ) + return ContactCreateResult(contact, created=True, error=None) except IntegrityError: Session.rollback() LOG.info( - f"Contact with email {email} for alias_id {alias.id} already existed, fetching from DB" + f"Contact with email {email} for alias_id {alias_id} already existed, fetching from DB" ) - contact = Contact.get_by(alias_id=alias.id, website_email=email) - return __update_contact_if_needed(contact, name, mail_from) - return ContactCreateResult(contact, created=True, error=None) + contact: Optional[Contact] = Contact.get_by( + alias_id=alias_id, website_email=email + ) + if contact: + return __update_contact_if_needed(contact, name, mail_from) + else: + LOG.warning( + f"Could not find contact with email {email} for alias_id {alias_id} and it should exist" + ) + return ContactCreateResult( + None, created=False, error=ContactCreateError.Unknown + ) diff --git a/app/app/jobs/send_event_job.py b/app/app/jobs/send_event_job.py new file mode 100644 index 0000000..3f5ca61 --- /dev/null +++ b/app/app/jobs/send_event_job.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import base64 +from typing import Optional + +import arrow + +from app import config +from app.errors import ProtonPartnerNotSetUp +from app.events.generated import event_pb2 +from app.events.generated.event_pb2 import EventContent +from app.models import ( + User, + Job, + PartnerUser, +) +from app.proton.utils import get_proton_partner +from events.event_sink import EventSink + + +class SendEventToWebhookJob: + def __init__(self, user: User, event: EventContent): + self._user: User = user + self._event: EventContent = event + + def run(self, sink: EventSink) -> bool: + # Check if the current user has a partner_id + try: + proton_partner_id = get_proton_partner().id + except ProtonPartnerNotSetUp: + return False + + # It has. Retrieve the information for the PartnerUser + partner_user = PartnerUser.get_by( + user_id=self._user.id, partner_id=proton_partner_id + ) + if partner_user is None: + return True + event = event_pb2.Event( + user_id=self._user.id, + external_user_id=partner_user.external_user_id, + partner_id=partner_user.partner_id, + content=self._event, + ) + + serialized = event.SerializeToString() + return sink.send_data_to_webhook(serialized) + + @staticmethod + def create_from_job(job: Job) -> Optional[SendEventToWebhookJob]: + user = User.get(job.payload["user_id"]) + if not user: + return None + event_data = base64.b64decode(job.payload["event"]) + event = event_pb2.EventContent() + event.ParseFromString(event_data) + + return SendEventToWebhookJob(user=user, event=event) + + def store_job_in_db(self, run_at: Optional[arrow.Arrow]) -> Job: + stub = self._event.SerializeToString() + return Job.create( + name=config.JOB_SEND_EVENT_TO_WEBHOOK, + payload={ + "user_id": self._user.id, + "event": base64.b64encode(stub).decode("utf-8"), + }, + run_at=run_at if run_at is not None else arrow.now(), + commit=True, + ) diff --git a/app/app/mailbox_utils.py b/app/app/mailbox_utils.py index 1949956..bc7f86b 100644 --- a/app/app/mailbox_utils.py +++ b/app/app/mailbox_utils.py @@ -37,8 +37,9 @@ class OnlyPaidError(MailboxError): class CannotVerifyError(MailboxError): - def __init__(self, msg: str): + def __init__(self, msg: str, deleted_activation_code: bool = False): self.msg = msg + self.deleted_activation_code = deleted_activation_code MAX_ACTIVATION_TRIES = 3 @@ -196,7 +197,10 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox: if activation.tries >= MAX_ACTIVATION_TRIES: LOG.i(f"User {user} failed to verify mailbox {mailbox_id} more than 3 times") clear_activation_codes_for_mailbox(mailbox) - raise CannotVerifyError("Invalid activation code. Please request another code.") + raise CannotVerifyError( + "Invalid activation code. Please request another code.", + deleted_activation_code=True, + ) if activation.created_at < arrow.now().shift(minutes=-15): LOG.i( f"User {user} failed to verify mailbox {mailbox_id} because code is too old" diff --git a/app/events/event_sink.py b/app/events/event_sink.py index e61e2e8..115f685 100644 --- a/app/events/event_sink.py +++ b/app/events/event_sink.py @@ -12,6 +12,10 @@ class EventSink(ABC): def process(self, event: SyncEvent) -> bool: pass + @abstractmethod + def send_data_to_webhook(self, data: bytes) -> bool: + pass + class HttpEventSink(EventSink): def process(self, event: SyncEvent) -> bool: @@ -21,9 +25,16 @@ class HttpEventSink(EventSink): LOG.info(f"Sending event {event.id} to {EVENT_WEBHOOK}") + if self.send_data_to_webhook(event.content): + LOG.info(f"Event {event.id} sent successfully to webhook") + return True + + return False + + def send_data_to_webhook(self, data: bytes) -> bool: res = requests.post( url=EVENT_WEBHOOK, - data=event.content, + data=data, headers={"Content-Type": "application/x-protobuf"}, verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL, ) @@ -36,7 +47,6 @@ class HttpEventSink(EventSink): ) return False else: - LOG.info(f"Event {event.id} sent successfully to webhook") return True @@ -44,3 +54,7 @@ class ConsoleEventSink(EventSink): def process(self, event: SyncEvent) -> bool: LOG.info(f"Handling event {event.id}") return True + + def send_data_to_webhook(self, data: bytes) -> bool: + LOG.info(f"Sending {len(data)} bytes to webhook") + return True diff --git a/app/job_runner.py b/app/job_runner.py index e2aefc1..89d6f2d 100644 --- a/app/job_runner.py +++ b/app/job_runner.py @@ -18,6 +18,7 @@ from app.events.event_dispatcher import PostgresDispatcher from app.import_utils import handle_batch_import from app.jobs.event_jobs import send_alias_creation_events_for_user from app.jobs.export_user_data_job import ExportUserDataJob +from app.jobs.send_event_job import SendEventToWebhookJob from app.log import LOG from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction @@ -300,6 +301,10 @@ def process_job(job: Job): send_alias_creation_events_for_user( user, dispatcher=PostgresDispatcher.get() ) + elif job.name == config.JOB_SEND_EVENT_TO_WEBHOOK: + send_job = SendEventToWebhookJob.create_from_job(job) + if send_job: + send_job.run() else: LOG.e("Unknown job name %s", job.name) diff --git a/app/tests/api/test_alias.py b/app/tests/api/test_alias.py index 1661b50..dd39e13 100644 --- a/app/tests/api/test_alias.py +++ b/app/tests/api/test_alias.py @@ -511,6 +511,19 @@ def test_create_contact_route_invalid_alias(flask_client): assert r.status_code == 403 +def test_create_contact_route_non_existing_alias(flask_client): + user, api_key = get_new_user_and_api_key() + Session.commit() + + r = flask_client.post( + url_for("api.create_contact_route", alias_id=99999999), + headers={"Authentication": api_key.code}, + json={"contact": "First Last "}, + ) + + assert r.status_code == 403 + + def test_create_contact_route_free_users(flask_client): user, api_key = get_new_user_and_api_key() diff --git a/app/tests/jobs/test_send_event_to_webhook.py b/app/tests/jobs/test_send_event_to_webhook.py new file mode 100644 index 0000000..dacc480 --- /dev/null +++ b/app/tests/jobs/test_send_event_to_webhook.py @@ -0,0 +1,40 @@ +import arrow + +from app import config +from app.events.generated.event_pb2 import EventContent, AliasDeleted +from app.jobs.send_event_job import SendEventToWebhookJob +from app.models import PartnerUser +from app.proton.utils import get_proton_partner +from events.event_sink import ConsoleEventSink +from tests.utils import create_new_user, random_token + + +def test_serialize_and_deserialize_job(): + user = create_new_user() + alias_id = 34 + alias_email = "a@b.c" + event = EventContent(alias_deleted=AliasDeleted(id=alias_id, email=alias_email)) + run_at = arrow.now().shift(hours=10) + db_job = SendEventToWebhookJob(user, event).store_job_in_db(run_at=run_at) + assert db_job.run_at == run_at + assert db_job.name == config.JOB_SEND_EVENT_TO_WEBHOOK + job = SendEventToWebhookJob.create_from_job(db_job) + assert job._user.id == user.id + assert job._event.alias_deleted.id == alias_id + assert job._event.alias_deleted.email == alias_email + + +def test_send_event_to_webhook(): + user = create_new_user() + PartnerUser.create( + user_id=user.id, + partner_id=get_proton_partner().id, + external_user_id=random_token(10), + flush=True, + ) + alias_id = 34 + alias_email = "a@b.c" + event = EventContent(alias_deleted=AliasDeleted(id=alias_id, email=alias_email)) + job = SendEventToWebhookJob(user, event) + sink = ConsoleEventSink() + assert job.run(sink) diff --git a/app/tests/test_mailbox_utils.py b/app/tests/test_mailbox_utils.py index 030bf13..d716b31 100644 --- a/app/tests/test_mailbox_utils.py +++ b/app/tests/test_mailbox_utils.py @@ -314,10 +314,13 @@ def test_verify_too_may(): output = mailbox_utils.create_mailbox(user, random_email()) output.activation.tries = mailbox_utils.MAX_ACTIVATION_TRIES Session.commit() - with pytest.raises(mailbox_utils.CannotVerifyError): + try: mailbox_utils.verify_mailbox_code( user, output.mailbox.id, output.activation.code ) + assert False + except mailbox_utils.CannotVerifyError as e: + assert e.deleted_activation_code @mail_sender.store_emails_test_decorator