diff --git a/CHANGES.rst b/CHANGES.rst index a3d2eb138..7166b04e0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -12,12 +12,20 @@ Changes Changes: -------- +- Add utility methods for `Job` to easily retrieve its various URLs. +- Add ``weaver.wps_email_notify_timeout`` setting (default 10s) to avoid SMTP server deadlock on failing connection. +- Modify the ``encrypt_email`` function to use an alternate strategy allowing ``decrypt_email`` on `Job` completed. - Add `CLI` ``execute`` options ``--output-public/-oP`` and ``--output-context/-oC OUTPUT_CONTEXT`` that add the specified ``X-WPS-Output-Context`` header to request the relevant output storage location of `Job` results. +- Remove ``notification_email`` from ``GET /jobs`` query parameters. + Due to the nature of the encryption strategy, this cannot be supported anymore. Fixes: ------ -- No change. +- Fix `Job` submitted with a ``notification_email`` not reversible from its encrypted value to retrieve the original + email on `Job` completion to send the notification (fixes `#568 `_). +- Fix example Mako Template for email notification using an unavailable property ``${logs}``. + Instead, the new utility methods ``job.[...]_url`` should be used to retrieve relevant locations. .. _changes_4.32.0: diff --git a/config/weaver.ini.example b/config/weaver.ini.example index 65c7a099f..e2cb1ecf0 100644 --- a/config/weaver.ini.example +++ b/config/weaver.ini.example @@ -148,6 +148,7 @@ weaver.wps_email_encrypt_rounds = 100000 weaver.wps_email_notify_smtp_host = weaver.wps_email_notify_from_addr = example@email.com weaver.wps_email_notify_password = 123456 +weaver.wps_email_notify_timeout = 10 weaver.wps_email_notify_port = 25 weaver.wps_email_notify_ssl = true weaver.wps_email_notify_template_dir = diff --git a/tests/test_notify.py b/tests/test_notify.py index 9e5b350e1..5f029d826 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -1,23 +1,150 @@ +import os +import smtplib +import tempfile +import uuid + +import mock import pytest -from weaver.notify import encrypt_email +from weaver.datatype import Job +from weaver.notify import decrypt_email, encrypt_email, notify_job_complete +from weaver.status import Status -def test_encrypt_email_valid(): +def test_encrypt_decrypt_email_valid(): settings = { "weaver.wps_email_encrypt_salt": "salty-email", } - email = encrypt_email("some@email.com", settings) - assert email == "a1724b030d999322e2ecc658453f992472c63867cd3cef3b3d829d745bd80f34" + email = "some@email.com" + token = encrypt_email(email, settings) + assert token != email + value = decrypt_email(token, settings) + assert value == email + + +def test_encrypt_email_random(): + email = "test@email.com" + settings = {"weaver.wps_email_encrypt_salt": "salty-email"} + token1 = encrypt_email(email, settings) + token2 = encrypt_email(email, settings) + token3 = encrypt_email(email, settings) + assert token1 != token2 != token3 + + # although encrypted are all different, they should all decrypt back to the original! + email1 = decrypt_email(token1, settings) + email2 = decrypt_email(token2, settings) + email3 = decrypt_email(token3, settings) + assert email1 == email2 == email3 == email -def test_encrypt_email_raise(): +@pytest.mark.parametrize("email_func", [encrypt_email, decrypt_email]) +def test_encrypt_decrypt_email_raise(email_func): with pytest.raises(TypeError): - encrypt_email("", {}) + email_func("", {}) pytest.fail("Should have raised for empty email") with pytest.raises(TypeError): - encrypt_email(1, {}) + email_func(1, {}) # type: ignore pytest.fail("Should have raised for wrong type") with pytest.raises(ValueError): - encrypt_email("ok@email.com", {}) + email_func("ok@email.com", {}) pytest.fail("Should have raised for invalid/missing settings") + + +def test_notify_job_complete(): + test_url = "https://test-weaver.example.com" + settings = { + "weaver.url": test_url, + "weaver.wps_email_notify_smtp_host": "xyz.test.com", + "weaver.wps_email_notify_from_addr": "test-weaver@email.com", + "weaver.wps_email_notify_password": "super-secret", + "weaver.wps_email_notify_port": 12345, + "weaver.wps_email_notify_timeout": 1, # quick fail if invalid + } + notify_email = "test-user@email.com" + test_job = Job( + task_id=uuid.uuid4(), + process="test-process", + settings=settings, + ) + test_job_err_url = f"{test_url}/processes/{test_job.process}/jobs/{test_job.id}/exceptions" + test_job_out_url = f"{test_url}/processes/{test_job.process}/jobs/{test_job.id}/results" + test_job_log_url = f"{test_url}/processes/{test_job.process}/jobs/{test_job.id}/logs" + + with mock.patch("smtplib.SMTP_SSL", autospec=smtplib.SMTP_SSL) as mock_smtp: + mock_smtp.return_value.sendmail.return_value = None # sending worked + + test_job.status = Status.SUCCEEDED + notify_job_complete(test_job, notify_email, settings) + mock_smtp.assert_called_with("xyz.test.com", 12345, timeout=1) + assert mock_smtp.return_value.sendmail.call_args[0][0] == "test-weaver@email.com" + assert mock_smtp.return_value.sendmail.call_args[0][1] == notify_email + message_encoded = mock_smtp.return_value.sendmail.call_args[0][2] + assert message_encoded + message = message_encoded.decode("utf8") + assert "From: Weaver" in message + assert f"To: {notify_email}" in message + assert f"Subject: Job {test_job.process} Succeeded" + assert test_job_out_url in message + assert test_job_log_url in message + assert test_job_err_url not in message + + test_job.status = Status.FAILED + notify_job_complete(test_job, notify_email, settings) + assert mock_smtp.return_value.sendmail.call_args[0][0] == "test-weaver@email.com" + assert mock_smtp.return_value.sendmail.call_args[0][1] == notify_email + message_encoded = mock_smtp.return_value.sendmail.call_args[0][2] + assert message_encoded + message = message_encoded.decode("utf8") + assert "From: Weaver" in message + assert f"To: {notify_email}" in message + assert f"Subject: Job {test_job.process} Failed" + assert test_job_out_url not in message + assert test_job_log_url in message + assert test_job_err_url in message + + +def test_notify_job_complete_custom_template(): + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".mako") as email_template_file: + email_template_file.writelines([ + "From: Weaver\n", + "To: ${to}\n", + "Subject: Job ${job.process} ${job.status}\n", + "\n", # end of email header, content below + "Job: ${job.status_url(settings)}\n", + ]) + email_template_file.flush() + email_template_file.seek(0) + + mako_dir, mako_name = os.path.split(email_template_file.name) + test_url = "https://test-weaver.example.com" + settings = { + "weaver.url": test_url, + "weaver.wps_email_notify_smtp_host": "xyz.test.com", + "weaver.wps_email_notify_from_addr": "test-weaver@email.com", + "weaver.wps_email_notify_password": "super-secret", + "weaver.wps_email_notify_port": 12345, + "weaver.wps_email_notify_timeout": 1, # quick fail if invalid + "weaver.wps_email_notify_template_dir": mako_dir, + "weaver.wps_email_notify_template_default": mako_name, + } + notify_email = "test-user@email.com" + test_job = Job( + task_id=uuid.uuid4(), + process="test-process", + status=Status.SUCCEEDED, + settings=settings, + ) + + with mock.patch("smtplib.SMTP_SSL", autospec=smtplib.SMTP_SSL) as mock_smtp: + mock_smtp.return_value.sendmail.return_value = None # sending worked + notify_job_complete(test_job, notify_email, settings) + + message_encoded = mock_smtp.return_value.sendmail.call_args[0][2] + message = message_encoded.decode("utf8") + assert message == "\n".join([ + "From: Weaver", + f"To: {notify_email}", + f"Subject: Job {test_job.process} {Status.SUCCEEDED}", + "", + f"Job: {test_url}/processes/{test_job.process}/jobs/{test_job.id}", + ]) diff --git a/tests/wps_restapi/test_jobs.py b/tests/wps_restapi/test_jobs.py index ab8fce90b..88b7213f1 100644 --- a/tests/wps_restapi/test_jobs.py +++ b/tests/wps_restapi/test_jobs.py @@ -1,4 +1,5 @@ import contextlib +import copy import datetime import logging import os @@ -34,6 +35,7 @@ from weaver.datatype import Job, Service from weaver.execute import ExecuteMode, ExecuteResponse, ExecuteTransmissionMode from weaver.formats import ContentType +from weaver.notify import decrypt_email from weaver.processes.wps_testing import WpsTestProcess from weaver.status import JOB_STATUS_CATEGORIES, Status, StatusCategory from weaver.utils import get_path_kvp, now @@ -543,6 +545,7 @@ def test_get_jobs_page_out_of_range(self): assert "limit" in str(resp.json["cause"]) and "less than minimum" in str(resp.json["cause"]) assert "limit" in resp.json["value"] and resp.json["value"]["limit"] == str(0) + @pytest.mark.skip(reason="Obsolete feature. It is not possible to filter by encrypted notification email anymore.") def test_get_jobs_by_encrypted_email(self): """ Verifies that literal email can be used as search criterion although not saved in plain text within db. @@ -562,13 +565,20 @@ def test_get_jobs_by_encrypted_email(self): resp = self.app.post_json(path, params=body, headers=self.json_headers) assert resp.status_code == 201 assert resp.content_type == ContentType.APP_JSON - job_id = resp.json["jobID"] + job_id = resp.json["jobID"] + + # submit a second job just to make sure email doesn't match it as well + other_body = copy.deepcopy(body) + other_body["notification_email"] = "random@email.com" + resp = self.app.post_json(path, params=other_body, headers=self.json_headers) + assert resp.status_code == 201 # verify the email is not in plain text job = self.job_store.fetch_by_id(job_id) assert job.notification_email != email and job.notification_email is not None - assert int(job.notification_email, 16) != 0 # email should be encrypted with hex string + assert decrypt_email(job.notification_email, self.settings) == email, "Email should be recoverable." + # make sure that jobs searched using email are found with encryption transparently for the user path = get_path_kvp(sd.jobs_service.path, detail="true", notification_email=email) resp = self.app.get(path, headers=self.json_headers) assert resp.status_code == 200 diff --git a/weaver/datatype.py b/weaver/datatype.py index ffc36d0ed..85a32aa89 100644 --- a/weaver/datatype.py +++ b/weaver/datatype.py @@ -925,17 +925,6 @@ def status_location(self, location_url): raise TypeError(f"Type 'str' is required for '{self.__name__}.status_location'") self["status_location"] = location_url - def status_url(self, container=None): - # type: (Optional[AnySettingsContainer]) -> str - """ - Obtain the resolved endpoint where the :term:`Job` status information can be obtained. - """ - settings = get_settings(container) - location_base = f"/providers/{self.service}" if self.service else "" - api_base_url = get_wps_restapi_base_url(settings) - location_url = f"{api_base_url}{location_base}/processes/{self.process}/jobs/{self.id}" - return location_url - @property def notification_email(self): # type: () -> Optional[str] @@ -1236,13 +1225,43 @@ def response(self, response): response = xml_util.tostring(response) self["response"] = response - def _job_url(self, base_url=None): - # type: (Optional[str]) -> str + def _job_url(self, base_url): + # type: (str) -> str if self.service is not None: base_url += sd.provider_service.path.format(provider_id=self.service) job_path = sd.process_job_service.path.format(process_id=self.process, job_id=self.id) return base_url + job_path + def job_url(self, container=None, extra_path=None): + # type: (Optional[AnySettingsContainer], Optional[str]) -> str + settings = get_settings(container) + base_url = get_wps_restapi_base_url(settings) + return self._job_url(base_url) + (extra_path or "") + + def status_url(self, container=None): + # type: (Optional[AnySettingsContainer]) -> str + return self.job_url(container=container) + + def logs_url(self, container=None): + # type: (Optional[AnySettingsContainer]) -> str + return self.job_url(container=container, extra_path="/logs") + + def exceptions_url(self, container=None): + # type: (Optional[AnySettingsContainer]) -> str + return self.job_url(container=container, extra_path="/exceptions") + + def inputs_url(self, container=None): + # type: (Optional[AnySettingsContainer]) -> str + return self.job_url(container=container, extra_path="/inputs") + + def outputs_url(self, container=None): + # type: (Optional[AnySettingsContainer]) -> str + return self.job_url(container=container, extra_path="/outputs") + + def results_url(self, container=None): + # type: (Optional[AnySettingsContainer]) -> str + return self.job_url(container=container, extra_path="/results") + def links(self, container=None, self_link=None): # type: (Optional[AnySettingsContainer], Optional[str]) -> List[Link] """ diff --git a/weaver/notify.py b/weaver/notify.py index d327256f8..f2e59fb9d 100644 --- a/weaver/notify.py +++ b/weaver/notify.py @@ -1,10 +1,14 @@ -import binascii -import hashlib +import base64 import logging import os +import secrets import smtplib from typing import TYPE_CHECKING +from cryptography.fernet import Fernet +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from mako.template import Template from pyramid.settings import asbool @@ -12,7 +16,7 @@ from weaver.utils import bytes2str, get_settings, str2bytes if TYPE_CHECKING: - from weaver.typedefs import AnySettingsContainer + from weaver.typedefs import AnySettingsContainer, SettingsType LOGGER = logging.getLogger(__name__) @@ -27,14 +31,17 @@ job: weaver.datatype.Job object settings: application settings - And every variable returned by the `weaver.datatype.Job.json` method: - status: succeeded, failed - logs: url to the logs - jobID: example "617f23d3-f474-47f9-a8ec-55da9dd6ac71" - result: url to the outputs - duration: example "0:01:02" - message: example "Job succeeded." - percentCompleted: example 100 + And every variable returned by the `weaver.datatype.Job.json` method. + Below is a non-exhaustive list of example parameters from this method. + Refer to the method for complete listing. + + status: succeeded, failed + logs: url to the logs + jobID: example "617f23d3-f474-47f9-a8ec-55da9dd6ac71" + result: url to the outputs + duration: example "0:01:02" + message: example "Job succeeded." + percentCompleted: example 100 From: Weaver To: ${to} @@ -46,15 +53,22 @@ Your job submitted on ${job.created.strftime("%Y/%m/%d %H:%M %Z")} to ${settings.get("weaver.url")} ${job.status}. % if job.status == "succeeded": -You can retrieve the output(s) at the following link: ${job.results[0]["reference"]} +You can retrieve the output(s) at the following link: ${job.results_url(settings)} +% elif job.status == "failed": +You can retrieve potential error details from the following link: ${job.exceptions_url(settings)} % endif -The logs are available here: ${logs} +The job logs are available at the following link: ${job.logs_url(settings)} Regards, Weaver """ +__SALT_LENGTH__ = 16 +__TOKEN_LENGTH__ = 32 +__ROUNDS_LENGTH__ = 4 +__DEFAULT_ROUNDS__ = 100_000 + def notify_job_complete(job, to_email_recipient, container): # type: (Job, str, AnySettingsContainer) -> None @@ -65,14 +79,16 @@ def notify_job_complete(job, to_email_recipient, container): smtp_host = settings.get("weaver.wps_email_notify_smtp_host") from_addr = settings.get("weaver.wps_email_notify_from_addr") password = settings.get("weaver.wps_email_notify_password") + timeout = int(settings.get("weaver.wps_email_notify_timeout") or 10) port = settings.get("weaver.wps_email_notify_port") ssl = asbool(settings.get("weaver.wps_email_notify_ssl", True)) # an example template is located in # weaver/wps_restapi/templates/notification_email_example.mako - template_dir = settings.get("weaver.wps_email_notify_template_dir") + template_dir = settings.get("weaver.wps_email_notify_template_dir") or "" if not smtp_host or not port: raise ValueError("The email server configuration is missing.") + port = int(port) # find appropriate template according to settings if not os.path.isdir(template_dir): @@ -95,9 +111,9 @@ def notify_job_complete(job, to_email_recipient, container): message = f"{contents}".strip("\n") if ssl: - server = smtplib.SMTP_SSL(smtp_host, port) + server = smtplib.SMTP_SSL(smtp_host, port, timeout=timeout) else: - server = smtplib.SMTP(smtp_host, port) + server = smtplib.SMTP(smtp_host, port, timeout=timeout) server.ehlo() try: server.starttls() @@ -117,16 +133,48 @@ def notify_job_complete(job, to_email_recipient, container): raise IOError(f"Code: {code}, Message: {error_message}") +# https://stackoverflow.com/a/55147077 +def get_crypto_key(settings, salt, rounds): + # type: (SettingsType, bytes, int) -> bytes + """ + Get the cryptographic key used for encoding and decoding the email. + """ + backend = default_backend() + pwd = str2bytes(settings.get("weaver.wps_email_encrypt_salt")) # use old param for backward-compat even if not salt + kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=__TOKEN_LENGTH__, salt=salt, iterations=rounds, backend=backend) + return base64.urlsafe_b64encode(kdf.derive(pwd)) + + def encrypt_email(email, settings): + # type: (str, SettingsType) -> str if not email or not isinstance(email, str): raise TypeError(f"Invalid email: {email!s}") - LOGGER.debug("Job email setup.") + LOGGER.debug("Job email encrypt.") try: - salt = str2bytes(settings.get("weaver.wps_email_encrypt_salt")) - email = str2bytes(email) - rounds = int(settings.get("weaver.wps_email_encrypt_rounds", 100000)) - derived_key = hashlib.pbkdf2_hmac("sha256", email, salt, rounds) - return bytes2str(binascii.hexlify(derived_key)) + salt = secrets.token_bytes(__SALT_LENGTH__) + rounds = int(settings.get("weaver.wps_email_encrypt_rounds", __DEFAULT_ROUNDS__)) + iters = rounds.to_bytes(__ROUNDS_LENGTH__, "big") + key = get_crypto_key(settings, salt, rounds) + msg = base64.urlsafe_b64decode(Fernet(key).encrypt(str2bytes(email))) + token = salt + iters + msg + return bytes2str(base64.urlsafe_b64encode(token)) except Exception as ex: - LOGGER.debug("Job email setup failed [%r].", ex) + LOGGER.debug("Job email encrypt failed [%r].", ex) raise ValueError("Cannot register job, server not properly configured for notification email.") + + +def decrypt_email(email, settings): + # type: (str, SettingsType) -> str + if not email or not isinstance(email, str): + raise TypeError(f"Invalid email: {email!s}") + LOGGER.debug("Job email decrypt.") + try: + token = base64.urlsafe_b64decode(str2bytes(email)) + salt = token[:__SALT_LENGTH__] + iters = int.from_bytes(token[__SALT_LENGTH__:__SALT_LENGTH__ + __ROUNDS_LENGTH__], "big") + token = base64.urlsafe_b64encode(token[__SALT_LENGTH__ + __ROUNDS_LENGTH__:]) + key = get_crypto_key(settings, salt, iters) + return bytes2str(Fernet(key).decrypt(token)) + except Exception as ex: + LOGGER.debug("Job email decrypt failed [%r].", ex) + raise ValueError("Cannot complete job, server not properly configured for notification email.") diff --git a/weaver/processes/execution.py b/weaver/processes/execution.py index 33b7e3f4d..81a05e01f 100644 --- a/weaver/processes/execution.py +++ b/weaver/processes/execution.py @@ -17,7 +17,7 @@ from weaver.datatype import Process, Service from weaver.execute import ExecuteControlOption, ExecuteMode from weaver.formats import AcceptLanguage, ContentType, clean_mime_type_format -from weaver.notify import encrypt_email, notify_job_complete +from weaver.notify import decrypt_email, encrypt_email, notify_job_complete from weaver.owsexceptions import OWSInvalidParameterValue, OWSNoApplicableCode from weaver.processes import wps_package from weaver.processes.constants import WPS_COMPLEX_DATA, JobInputsOutputsSchema @@ -459,7 +459,8 @@ def send_job_complete_notification_email(job, task_logger, settings): """ if job.notification_email is not None: try: - notify_job_complete(job, job.notification_email, settings) + email = decrypt_email(job.notification_email, settings) + notify_job_complete(job, email, settings) message = "Notification email sent successfully." job.save_log(logger=task_logger, message=message) except Exception as exc: diff --git a/weaver/store/base.py b/weaver/store/base.py index a9e38395e..cc36af096 100644 --- a/weaver/store/base.py +++ b/weaver/store/base.py @@ -217,7 +217,6 @@ def find_jobs(self, job_type=None, # type: Optional[str] tags=None, # type: Optional[List[str]] access=None, # type: Optional[str] - notification_email=None, # type: Optional[str] status=None, # type: Optional[AnyStatusSearch, List[AnyStatusSearch]] sort=None, # type: Optional[AnySortType] page=0, # type: Optional[int] diff --git a/weaver/store/mongodb.py b/weaver/store/mongodb.py index a206c6825..7e2cc701d 100644 --- a/weaver/store/mongodb.py +++ b/weaver/store/mongodb.py @@ -78,12 +78,19 @@ AnyVersion, ExecutionInputs, ExecutionOutputs, - JSON + JSON, + SettingsType ) from weaver.visibility import AnyVisibility MongodbValue = Union[AnyValueType, datetime.datetime] - MongodbAggregateValue = Union[MongodbValue, List[MongodbValue], Dict[str, AnyValueType], List[AnyValueType]] + MongodbAggregateValue = Union[ + MongodbValue, + List[MongodbValue], + List[AnyValueType], + Dict[str, AnyValueType], + Dict[str, List[AnyValueType]], + ] MongodbAggregateSortOrder = Dict[str, int] MongodbAggregateSortExpression = TypedDict("MongodbAggregateSortExpression", { "$sort": MongodbAggregateSortOrder, @@ -553,7 +560,7 @@ def list_processes(self, if visibility is None: visibility = Visibility.values() if not isinstance(visibility, list): - visibility = [visibility] + visibility = [visibility] # type: List[str] for v in visibility: vis = Visibility.get(v) if vis not in Visibility: @@ -922,7 +929,6 @@ def find_jobs(self, job_type=None, # type: Optional[str] tags=None, # type: Optional[List[str]] access=None, # type: Optional[str] - notification_email=None, # type: Optional[str] status=None, # type: Optional[AnyStatusSearch, List[AnyStatusSearch]] sort=None, # type: Optional[AnySortType] page=0, # type: Optional[int] @@ -973,7 +979,6 @@ def find_jobs(self, :param job_type: filter matching jobs for given type. :param tags: list of tags to filter matching jobs. :param access: access visibility to filter matching jobs (default: :py:data:`Visibility.PUBLIC`). - :param notification_email: notification email to filter matching jobs. :param status: status to filter matching jobs. :param sort: field which is used for sorting results (default: creation date, descending). :param page: page number to return when using result paging (only when not using ``group_by``). @@ -985,9 +990,6 @@ def find_jobs(self, :returns: (list of jobs matching paging OR list of {categories, list of jobs, count}) AND total of matched job. """ search_filters = {} - if notification_email is not None: - search_filters["notification_email"] = notification_email - search_filters.update(self._apply_status_filter(status)) search_filters.update(self._apply_ref_or_type_filter(job_type, process, service)) search_filters.update(self._apply_tags_filter(tags)) diff --git a/weaver/wps_restapi/jobs/jobs.py b/weaver/wps_restapi/jobs/jobs.py index 6c87e39bd..a4b6df8ae 100644 --- a/weaver/wps_restapi/jobs/jobs.py +++ b/weaver/wps_restapi/jobs/jobs.py @@ -8,7 +8,6 @@ from weaver.datatype import Job from weaver.exceptions import JobNotFound, JobStatisticsNotFound, log_unhandled_exceptions from weaver.formats import ContentType, OutputFormat, add_content_type_charset, guess_target_format, repr_json -from weaver.notify import encrypt_email from weaver.processes.convert import convert_input_values_schema, convert_output_params_schema from weaver.status import JOB_STATUS_CATEGORIES, Status, StatusCategory from weaver.store.base import StoreJobs @@ -74,10 +73,6 @@ def get_queried_jobs(request): detail = filters.pop("detail", False) groups = filters.pop("groups", None) filters["status"] = filters["status"].split(",") if "status" in filters else None - filters["notification_email"] = ( - encrypt_email(filters["notification_email"], settings) - if filters.get("notification_email", False) else None - ) filters["min_duration"] = filters.pop("minDuration", None) filters["max_duration"] = filters.pop("maxDuration", None) filters["job_type"] = filters.pop("type", None) diff --git a/weaver/wps_restapi/swagger_definitions.py b/weaver/wps_restapi/swagger_definitions.py index 57acf52f2..40bbd4e28 100644 --- a/weaver/wps_restapi/swagger_definitions.py +++ b/weaver/wps_restapi/swagger_definitions.py @@ -5542,7 +5542,6 @@ class GetJobsQueries(PagingQueries): description="Filter jobs only to matching type (note: 'service' and 'provider' are aliases).") sort = JobSortEnum(missing=drop) access = JobAccess(missing=drop, default=None) - notification_email = ExtendedSchemaNode(String(), missing=drop, validator=Email()) tags = JobTagsCommaSeparated()