diff --git a/Changelog.md b/Changelog.md
index d51bd0965f45cdf749cef2b164f513fac6e34e16..f30551fb13bda0c07651e3fe21b783276c4a59d6 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -2,6 +2,11 @@
 
 All notable changes to this project will be documented in this file.
 
+## [2.9] - 2024-08-06
+- IP trunk validation workflow no longer runs for Juniper-only trunks
+- Automatically run pre- and post-checks when modifying an IP trunk
+- Small improvements to email notifications
+
 ## [2.8] - 2024-08-01
 - Reworked authentication components
 - Add a task for sending email notifications in case validation workflows have failed
diff --git a/gso/__init__.py b/gso/__init__.py
index 2df27bcb762638dee24e7c6449b34f7e99b4782d..f1d1debdc8208a7368f90d811d586679367f9cad 100644
--- a/gso/__init__.py
+++ b/gso/__init__.py
@@ -47,8 +47,10 @@ def init_cli_app() -> typer.Typer:
 
 def init_sentry() -> None:
     """Only initialize Sentry if not in testing mode."""
-    if os.getenv("TESTING", "false").lower() == "false" and (sentry_config := load_oss_params().SENTRY):
-        sentry_sdk.init(dsn=sentry_config.DSN, environment=sentry_config.environment, traces_sample_rate=1.0)
+    if os.getenv("TESTING", "false").lower() == "false" and (sentry_params := load_oss_params().SENTRY):
+        sentry_sdk.init(
+            dsn=sentry_params.DSN, environment=load_oss_params().GENERAL.environment, traces_sample_rate=1.0
+        )
 
 
 init_sentry()
diff --git a/gso/api/v1/network.py b/gso/api/v1/network.py
index 9109fd6fcde9ca14825b64d506082fb68037eb54..9b385c8e86330e5929a8e7b1a882faf07b966da0 100644
--- a/gso/api/v1/network.py
+++ b/gso/api/v1/network.py
@@ -62,6 +62,7 @@ class IptrunkBlock(OrchestratorBaseModel):
     iptrunk_capacity: str
     iptrunk_isis_metric: int
     iptrunk_sides: list[IptrunkSideBlock]
+    geant_s_sid: str
 
 
 class IptrunkSchema(OrchestratorBaseModel):
@@ -106,6 +107,7 @@ def network_topology() -> NetworkTopologyDomainModelSchema:
                 "iptrunk_capacity": _calculate_iptrunk_capacity(
                     extended_model["iptrunk"]["iptrunk_sides"], extended_model["iptrunk"]["iptrunk_speed"]
                 ),
+                "geant_s_sid": extended_model["iptrunk"]["geant_s_sid"],
                 "iptrunk_sides": [
                     {
                         "subscription_instance_id": side["subscription_instance_id"],
diff --git a/gso/oss-params-example.json b/gso/oss-params-example.json
index f9523a724d2b433b761ef4c5f33544077e6d850b..069672ffda3c401e850b7d146d37259898ebbf46 100644
--- a/gso/oss-params-example.json
+++ b/gso/oss-params-example.json
@@ -1,7 +1,9 @@
 {
   "GENERAL": {
     "public_hostname": "https://gap.geant.org",
-    "isis_high_metric": 999999
+    "internal_hostname": "http://gso-api:9000",
+    "isis_high_metric": 999999,
+    "environment": "development"
   },
   "NETBOX": {
     "api": "https://127.0.0.1:8000",
@@ -119,7 +121,6 @@
     "md5_password": "snmp password"
   },
   "SENTRY": {
-    "DSN": "https://sentry-dsn-url",
-    "environment": "development"
+    "DSN": "https://sentry-dsn-url"
   }
 }
diff --git a/gso/services/lso_client.py b/gso/services/lso_client.py
index 6e1ec8fa039dfddbe12bacf3e4c5b215ef7c3635..6eca3f6559943df80660b3f71ba50e9c1feb1d64 100644
--- a/gso/services/lso_client.py
+++ b/gso/services/lso_client.py
@@ -37,7 +37,7 @@ def _send_request(parameters: dict, callback_route: str) -> None:
     params = oss.PROVISIONING_PROXY
 
     # Build up a callback URL of the Provisioning Proxy to return its results to.
-    callback_url = f"{oss.GENERAL.public_hostname}{callback_route}"
+    callback_url = f"{oss.GENERAL.internal_hostname}{callback_route}"
     debug_msg = f"[provisioning proxy] Callback URL set to {callback_url}"
     logger.debug(debug_msg)
 
diff --git a/gso/settings.py b/gso/settings.py
index c105fffc8fbb23933a12a572f208d0bd07cd5b9e..6c6378dd196915b26626a80ac31f8282a795791d 100644
--- a/gso/settings.py
+++ b/gso/settings.py
@@ -21,13 +21,24 @@ from gso.utils.shared_enums import PortNumber
 logger = logging.getLogger(__name__)
 
 
+class EnvironmentEnum(strEnum):
+    """The different environments in which the GSO system can run."""
+
+    DEVELOPMENT = "development"
+    TEST = "test"
+    UAT = "uat"
+    PRODUCTION = "production"
+
+
 class GeneralParams(BaseSettings):
     """General parameters for a :term:`GSO` configuration file."""
 
     public_hostname: str
-    """The hostname that :term:`GSO` is publicly served at, used for building the callback URL that the provisioning
-    proxy uses."""
+    """The hostname that :term:`GSO` is publicly served at, used for building callback URLs for public use."""
+    internal_hostname: str
+    """The hostname of :term:`GSO` that is for internal use, such as the provisioning proxy."""
     isis_high_metric: int
+    environment: EnvironmentEnum
 
 
 class CeleryParams(BaseSettings):
@@ -192,20 +203,10 @@ class KentikParams(BaseSettings):
     md5_password: str
 
 
-class EnvironmentEnum(strEnum):
-    """The different environments in which the GSO system can run."""
-
-    DEVELOPMENT = "development"
-    TEST = "test"
-    UAT = "uat"
-    PRODUCTION = "production"
-
-
 class SentryParams(BaseSettings):
     """Settings for Sentry."""
 
     DSN: str
-    environment: EnvironmentEnum
 
 
 class OSSParams(BaseSettings):
diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py
index d64331720fcb85cccc829f58d6d3d8b437e55b77..7ed4c0bc110c827aa5e7880f5d723d8a8b28e549 100644
--- a/gso/workflows/iptrunk/modify_trunk_interface.py
+++ b/gso/workflows/iptrunk/modify_trunk_interface.py
@@ -34,6 +34,8 @@ from gso.utils.helpers import (
     validate_tt_number,
 )
 from gso.utils.shared_enums import IPv4AddressType, IPv6AddressType, Vendor
+from gso.workflows.iptrunk.migrate_iptrunk import check_ip_trunk_optical_levels_pre
+from gso.workflows.iptrunk.validate_iptrunk import check_ip_trunk_isis
 
 T = TypeVar("T", bound=LAGMember)
 
@@ -175,6 +177,69 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
     )
 
 
+@step("Determine whether we should be running interface checks")
+def determine_change_in_capacity(
+    subscription: Iptrunk, iptrunk_speed: str, side_a_ae_members: list[LAGMember], side_b_ae_members: list[LAGMember]
+) -> State:
+    """Determine whether we should run pre- and post-checks on the IP trunk.
+
+    This can be caused by the following conditions:
+     * The total capacity of the trunk changes
+     * The amount of interfaces changes
+     * One or more interface names have changed on side A
+     * One or more interface names have changed on side B
+    """
+    capacity_has_changed = (
+        iptrunk_speed != subscription.iptrunk.iptrunk_speed
+        or len(side_a_ae_members) != len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members)
+        or any(
+            old_interface.interface_name != new_interface.interface_name
+            for old_interface, new_interface in zip(
+                subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members, side_a_ae_members, strict=False
+            )
+        )
+        or any(
+            old_interface.interface_name != new_interface.interface_name
+            for old_interface, new_interface in zip(
+                subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members, side_b_ae_members, strict=False
+            )
+        )
+    )
+
+    return {"capacity_has_changed": capacity_has_changed}
+
+
+@step("Check IP connectivity of the trunk")
+def check_ip_trunk_connectivity(subscription: Iptrunk, callback_route: str) -> State:
+    """Check successful connectivity across a trunk."""
+    extra_vars = {"wfo_ip_trunk_json": json.loads(json_dumps(subscription)), "check": "ping"}
+
+    execute_playbook(
+        playbook_name="iptrunks_checks.yaml",
+        callback_route=callback_route,
+        inventory=subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn,
+        extra_vars=extra_vars,
+    )
+
+    return {"subscription": subscription}
+
+
+@step("Check LLDP on the trunk endpoints")
+def check_ip_trunk_lldp(subscription: Iptrunk, callback_route: str) -> State:
+    """Check LLDP on trunk endpoints."""
+    extra_vars = {"wfo_ip_trunk_json": json.loads(json_dumps(subscription)), "check": "lldp"}
+
+    execute_playbook(
+        playbook_name="iptrunks_checks.yaml",
+        callback_route=callback_route,
+        inventory=f"{subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn}\n"
+        f"{subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn}\n",
+        extra_vars=extra_vars,
+    )
+
+    return {"subscription": subscription}
+
+
 @step("Update subscription")
 def modify_iptrunk_subscription(
     subscription: Iptrunk,
@@ -397,6 +462,22 @@ def allocate_interfaces_in_netbox_side_b(subscription: Iptrunk, previous_ae_memb
     _netbox_allocate_interfaces(subscription.iptrunk.iptrunk_sides[1], previous_ae_members[1])
 
 
+@step("Check Optical POST levels on the trunk endpoint")
+def check_ip_trunk_optical_levels_post(subscription: Iptrunk, callback_route: str) -> State:
+    """Check Optical POST levels on the trunk."""
+    extra_vars = {"wfo_ip_trunk_json": json.loads(json_dumps(subscription)), "check": "optical_post"}
+
+    execute_playbook(
+        playbook_name="iptrunks_checks.yaml",
+        callback_route=callback_route,
+        inventory=f"{subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn}\n"
+        f"{subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn}\n",
+        extra_vars=extra_vars,
+    )
+
+    return {"subscription": subscription}
+
+
 @workflow(
     "Modify IP Trunk interface",
     initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator),
@@ -422,10 +503,17 @@ def modify_trunk_interface() -> StepList:
         )
         == Vendor.NOKIA
     )
+    capacity_has_changed = conditional(lambda state: state["capacity_has_changed"])
+
     return (
         begin
         >> store_process_subscription(Target.MODIFY)
         >> unsync
+        >> determine_change_in_capacity
+        >> capacity_has_changed(lso_interaction(check_ip_trunk_lldp))
+        >> capacity_has_changed(lso_interaction(check_ip_trunk_optical_levels_pre))
+        >> capacity_has_changed(lso_interaction(check_ip_trunk_connectivity))
+        >> capacity_has_changed(lso_interaction(check_ip_trunk_isis))
         >> modify_iptrunk_subscription
         >> side_a_is_nokia(netbox_update_interfaces_side_a)
         >> side_b_is_nokia(netbox_update_interfaces_side_b)
@@ -433,6 +521,10 @@ def modify_trunk_interface() -> StepList:
         >> lso_interaction(provision_ip_trunk_iface_real)
         >> side_a_is_nokia(allocate_interfaces_in_netbox_side_a)
         >> side_b_is_nokia(allocate_interfaces_in_netbox_side_b)
+        >> capacity_has_changed(lso_interaction(check_ip_trunk_lldp))
+        >> capacity_has_changed(lso_interaction(check_ip_trunk_optical_levels_post))
+        >> capacity_has_changed(lso_interaction(check_ip_trunk_connectivity))
+        >> capacity_has_changed(lso_interaction(check_ip_trunk_isis))
         >> resync
         >> done
     )
diff --git a/gso/workflows/iptrunk/validate_iptrunk.py b/gso/workflows/iptrunk/validate_iptrunk.py
index 5b2e3f50cf4615962096af32f4838579f418a4cc..bdfc4d3b952eca2e998d46ceab47250a56cfdb21 100644
--- a/gso/workflows/iptrunk/validate_iptrunk.py
+++ b/gso/workflows/iptrunk/validate_iptrunk.py
@@ -5,7 +5,7 @@ import json
 from orchestrator.targets import Target
 from orchestrator.utils.errors import ProcessFailureError
 from orchestrator.utils.json import json_dumps
-from orchestrator.workflow import StepList, begin, done, step, workflow
+from orchestrator.workflow import StepList, begin, conditional, done, step, workflow
 from orchestrator.workflows.steps import resync, store_process_subscription, unsync
 from orchestrator.workflows.utils import wrap_modify_initial_input_form
 
@@ -192,10 +192,20 @@ def validate_iptrunk() -> StepList:
     * Verify the configuration on both sides of the trunk is intact.
     * Check the ISIS metric of the trunk.
     * Verify that TWAMP configuration is correct.
+
+    If a trunk has a Juniper router on both sides, it is considered legacy and does not require validation.
     """
+    skip_legacy_trunks = conditional(
+        lambda state: all(
+            side.iptrunk_side_node.vendor == Vendor.JUNIPER
+            for side in Iptrunk.from_subscription(state["subscription_id"]).iptrunk.iptrunk_sides
+        )
+    )
+
     return (
         begin
         >> store_process_subscription(Target.SYSTEM)
+        >> skip_legacy_trunks(done)
         >> unsync
         >> verify_ipam_records
         >> verify_netbox_entries
diff --git a/gso/workflows/tasks/send_email_notifications.py b/gso/workflows/tasks/send_email_notifications.py
index e30916cc69ac75ca3114fd46ae94fb6ccf275f1f..f346cf1f5d14db2f1b00e9307ae22b83b609066d 100644
--- a/gso/workflows/tasks/send_email_notifications.py
+++ b/gso/workflows/tasks/send_email_notifications.py
@@ -18,10 +18,10 @@ def gather_failed_tasks() -> State:
 @step("Send notification emails for all failed tasks")
 def send_email_notifications(state: State) -> None:
     """Send out an email notification for all tasks that have failed."""
-    base_url = load_oss_params().GENERAL.public_hostname
+    general_settings = load_oss_params().GENERAL
     all_alerts = ""
     for failure in state["failed_tasks"]:
-        failed_task_url = f"{base_url}/workflows/{failure["process_id"]}"
+        failed_task_url = f"{general_settings.public_hostname}/workflows/{failure["process_id"]}"
         failed_subscription = get_subscription_by_process_id(failure["process_id"])
         all_alerts = f"{all_alerts}------\n\n"
         if failed_subscription:
@@ -36,7 +36,7 @@ def send_email_notifications(state: State) -> None:
         )
 
     send_mail(
-        "GAP - One or more tasks have failed!",
+        f"GAP {general_settings.environment} environment - One or more tasks have failed!",
         (
             f"Please check the following tasks in GAP which have failed.\n\n{all_alerts}------"
             f"\n\nRegards, the GÉANT Automation Platform.\n\n"
diff --git a/setup.py b/setup.py
index a70761c82a2232f6bd63b2cb01f9deb5e1f0a184..05d2fd0f212ec9bb1c4a0edf367be14b60750dd7 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@ from setuptools import find_packages, setup
 
 setup(
     name="geant-service-orchestrator",
-    version="2.8",
+    version="2.9",
     author="GÉANT Orchestration and Automation Team",
     author_email="goat@geant.org",
     description="GÉANT Service Orchestrator",
diff --git a/test/workflows/iptrunk/test_modify_trunk_interface.py b/test/workflows/iptrunk/test_modify_trunk_interface.py
index 2a95cc90fc6428dd19c7e9a2b0a0f77a58ef3a2b..1383ea2edc1380613900ae62b0024b4622ac6d57 100644
--- a/test/workflows/iptrunk/test_modify_trunk_interface.py
+++ b/test/workflows/iptrunk/test_modify_trunk_interface.py
@@ -90,7 +90,7 @@ def input_form_iptrunk_data(
     indirect=True,
 )
 @pytest.mark.workflow()
-@patch("gso.workflows.iptrunk.modify_trunk_interface.execute_playbook")
+@patch("gso.services.lso_client._send_request")
 @patch("gso.services.netbox_client.NetboxClient.get_available_interfaces")
 @patch("gso.services.netbox_client.NetboxClient.attach_interface_to_lag")
 @patch("gso.services.netbox_client.NetboxClient.reserve_interface")
@@ -120,8 +120,10 @@ def test_iptrunk_modify_trunk_interface_success(
 
     #  Run workflow
     result, process_stat, step_log = run_workflow("modify_trunk_interface", input_form_iptrunk_data)
+    state = extract_state(result)
+    lso_interaction_count = 10 if state["capacity_has_changed"] else 2
 
-    for _ in range(2):
+    for _ in range(lso_interaction_count):
         result, step_log = assert_lso_interaction_success(result, process_stat, step_log)
 
     assert_complete(result)
@@ -131,7 +133,7 @@ def test_iptrunk_modify_trunk_interface_success(
     subscription = Iptrunk.from_subscription(subscription_id)
 
     assert subscription.status == "active"
-    assert mock_provision_ip_trunk.call_count == 2
+    assert mock_provision_ip_trunk.call_count == lso_interaction_count
     # Assert all Netbox calls have been made
     new_sid = input_form_iptrunk_data[1]["geant_s_sid"]
     new_side_a_sid = input_form_iptrunk_data[3]["side_a_ae_geant_a_sid"]
diff --git a/test/workflows/iptrunk/test_validate_iptrunk.py b/test/workflows/iptrunk/test_validate_iptrunk.py
index 48de3d1bc069f879ae307273544abbeef57ebc5b..13bcaed9b4be4284a6f1dc5fa4e41410d2f8dab6 100644
--- a/test/workflows/iptrunk/test_validate_iptrunk.py
+++ b/test/workflows/iptrunk/test_validate_iptrunk.py
@@ -4,6 +4,7 @@ import pytest
 from infoblox_client import objects
 
 from gso.products.product_types.iptrunk import Iptrunk
+from test.conftest import UseJuniperSide
 from test.services.conftest import MockedNetboxClient
 from test.workflows import (
     assert_complete,
@@ -13,6 +14,31 @@ from test.workflows import (
 )
 
 
+@pytest.fixture()
+def trunk_validation_setup(
+    request,
+    iptrunk_subscription_factory,
+    juniper_router_subscription_factory,
+    nokia_router_subscription_factory,
+    iptrunk_side_subscription_factory,
+):
+    if request.param == UseJuniperSide.SIDE_A:
+        # Nokia -> Juniper
+        side_a = iptrunk_side_subscription_factory(iptrunk_side_node=nokia_router_subscription_factory())
+        side_b = iptrunk_side_subscription_factory(iptrunk_side_node=juniper_router_subscription_factory())
+    elif request.param == UseJuniperSide.SIDE_B:
+        # Juniper -> Nokia
+        side_a = iptrunk_side_subscription_factory(iptrunk_side_node=juniper_router_subscription_factory())
+        side_b = iptrunk_side_subscription_factory(iptrunk_side_node=nokia_router_subscription_factory())
+    else:
+        # Nokia -> Nokia
+        side_a = iptrunk_side_subscription_factory(iptrunk_side_node=nokia_router_subscription_factory())
+        side_b = iptrunk_side_subscription_factory(iptrunk_side_node=nokia_router_subscription_factory())
+
+    subscription_id = iptrunk_subscription_factory(iptrunk_sides=[side_a, side_b])
+    return [{"subscription_id": subscription_id}]
+
+
 @pytest.fixture()
 def _mocked_netbox_client():
     with (
@@ -35,6 +61,9 @@ def _mocked_netbox_client():
         yield
 
 
+@pytest.mark.parametrize(
+    "trunk_validation_setup", [UseJuniperSide.NONE, UseJuniperSide.SIDE_A, UseJuniperSide.SIDE_B], indirect=True
+)
 @pytest.mark.workflow()
 @pytest.mark.usefixtures("_mocked_netbox_client")
 @patch("gso.services.infoblox.find_network_by_cidr")
@@ -50,10 +79,10 @@ def test_validate_iptrunk_success(
     mock_find_network_by_cidr,
     faker,
     data_config_filename,
-    iptrunk_subscription_factory,
+    trunk_validation_setup,
 ):
     #  Mock value setup
-    subscription_id = iptrunk_subscription_factory()
+    subscription_id = trunk_validation_setup[0]["subscription_id"]
     trunk = Iptrunk.from_subscription(subscription_id).iptrunk
     mock_find_network_by_cidr.side_effects = [
         objects.Network(connector=None, ipv4addrs=[trunk.iptrunk_ipv4_network]),
@@ -183,3 +212,44 @@ def test_validate_iptrunk_success(
     assert mock_find_host_by_fqdn.call_count == 2
     assert mock_find_v6_host_by_fqdn.call_count == 2
     assert mock_find_network_by_cidr.call_count == 2
+
+
+@pytest.mark.workflow()
+@pytest.mark.usefixtures("_mocked_netbox_client")
+@patch("gso.services.infoblox.find_network_by_cidr")
+@patch("gso.services.infoblox.find_v6_host_by_fqdn")
+@patch("gso.services.infoblox.find_host_by_fqdn")
+@patch("gso.workflows.iptrunk.validate_iptrunk.execute_playbook")
+@patch("gso.services.netbox_client.NetboxClient.get_interface_by_name_and_device")
+def test_validate_iptrunk_skip_legacy_trunks(
+    mock_get_interface_by_name,
+    mock_validate_iptrunk,
+    mock_find_host_by_fqdn,
+    mock_find_v6_host_by_fqdn,
+    mock_find_network_by_cidr,
+    faker,
+    data_config_filename,
+    iptrunk_subscription_factory,
+    iptrunk_side_subscription_factory,
+    juniper_router_subscription_factory,
+):
+    #  Mock value setup
+    side_a = iptrunk_side_subscription_factory(iptrunk_side_node=juniper_router_subscription_factory())
+    side_b = iptrunk_side_subscription_factory(iptrunk_side_node=juniper_router_subscription_factory())
+    subscription_id = iptrunk_subscription_factory(iptrunk_sides=[side_a, side_b])
+
+    #  Run workflow
+    initial_router_data = [{"subscription_id": subscription_id}]
+    result, _, _ = run_workflow("validate_iptrunk", initial_router_data)
+    state = extract_state(result)
+    assert_complete(result)
+
+    subscription_id = state["subscription_id"]
+    subscription = Iptrunk.from_subscription(subscription_id)
+
+    assert subscription.status == "active"
+    assert mock_get_interface_by_name.call_count == 0
+    assert mock_validate_iptrunk.call_count == 0
+    assert mock_find_host_by_fqdn.call_count == 0
+    assert mock_find_v6_host_by_fqdn.call_count == 0
+    assert mock_find_network_by_cidr.call_count == 0