selenium-outlook-mail-notifier 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maschior
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: selenium-outlook-mail-notifier
3
+ Version: 0.1.0
4
+ Summary: Selenium-based Outlook email notifier
5
+ Project-URL: Homepage, https://github.com/Maschior/selenium-outlook-mail-notifier
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: selenium>=4.49.0
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest>=8.0.0; extra == "test"
12
+ Dynamic: license-file
13
+
14
+ # selenium-outlook-mail-notifier
15
+
16
+ [![Tests](https://github.com/Maschior/selenium-outlook-mail-notifier/actions/workflows/tests.yml/badge.svg)](https://github.com/Maschior/selenium-outlook-mail-notifier/actions/workflows/tests.yml)
17
+ [![PyPI](https://img.shields.io/pypi/v/selenium-outlook-mail-notifier.svg)](https://pypi.org/project/selenium-outlook-mail-notifier/)
18
+ [![Python versions](https://img.shields.io/pypi/pyversions/selenium-outlook-mail-notifier.svg)](https://pypi.org/project/selenium-outlook-mail-notifier/)
19
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
20
+
21
+ Send an email through Outlook Web (outlook.office.com) by driving a real
22
+ browser session with Selenium — useful when you don't have Microsoft Graph
23
+ API access but still need to trigger email notifications from a script.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install selenium-outlook-mail-notifier
29
+ ```
30
+
31
+ Requires Chrome and a matching chromedriver available on PATH (or managed by
32
+ Selenium Manager, which ships with Selenium 4.6+).
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ from selenium_outlook_mail_notifier import notify
38
+
39
+ notify(
40
+ subject="Something happened",
41
+ body="Body message",
42
+ recipient="someone@example.com", # optional, falls back to OUTLOOK_RECIPIENT
43
+ cc=["other@example.com"], # optional, falls back to OUTLOOK_CC
44
+ )
45
+ ```
46
+
47
+ Credentials are never passed as literals in code. `notify()` reads them from
48
+ environment variables unless you pass them explicitly:
49
+
50
+ | Variable | Required | Description |
51
+ |---------------------|----------|---------------------------------------|
52
+ | `OUTLOOK_EMAIL` | yes | Outlook account used to send the mail |
53
+ | `OUTLOOK_PASSWORD` | yes | Password for that account |
54
+ | `OUTLOOK_RECIPIENT` | no | Default recipient if not passed |
55
+ | `OUTLOOK_CC` | no | `;`-separated list of CC addresses |
56
+
57
+ ## Development
58
+
59
+ ```bash
60
+ pip install -e ".[test]"
61
+ pytest
62
+ ```
@@ -0,0 +1,49 @@
1
+ # selenium-outlook-mail-notifier
2
+
3
+ [![Tests](https://github.com/Maschior/selenium-outlook-mail-notifier/actions/workflows/tests.yml/badge.svg)](https://github.com/Maschior/selenium-outlook-mail-notifier/actions/workflows/tests.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/selenium-outlook-mail-notifier.svg)](https://pypi.org/project/selenium-outlook-mail-notifier/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/selenium-outlook-mail-notifier.svg)](https://pypi.org/project/selenium-outlook-mail-notifier/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+
8
+ Send an email through Outlook Web (outlook.office.com) by driving a real
9
+ browser session with Selenium — useful when you don't have Microsoft Graph
10
+ API access but still need to trigger email notifications from a script.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install selenium-outlook-mail-notifier
16
+ ```
17
+
18
+ Requires Chrome and a matching chromedriver available on PATH (or managed by
19
+ Selenium Manager, which ships with Selenium 4.6+).
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ from selenium_outlook_mail_notifier import notify
25
+
26
+ notify(
27
+ subject="Something happened",
28
+ body="Body message",
29
+ recipient="someone@example.com", # optional, falls back to OUTLOOK_RECIPIENT
30
+ cc=["other@example.com"], # optional, falls back to OUTLOOK_CC
31
+ )
32
+ ```
33
+
34
+ Credentials are never passed as literals in code. `notify()` reads them from
35
+ environment variables unless you pass them explicitly:
36
+
37
+ | Variable | Required | Description |
38
+ |---------------------|----------|---------------------------------------|
39
+ | `OUTLOOK_EMAIL` | yes | Outlook account used to send the mail |
40
+ | `OUTLOOK_PASSWORD` | yes | Password for that account |
41
+ | `OUTLOOK_RECIPIENT` | no | Default recipient if not passed |
42
+ | `OUTLOOK_CC` | no | `;`-separated list of CC addresses |
43
+
44
+ ## Development
45
+
46
+ ```bash
47
+ pip install -e ".[test]"
48
+ pytest
49
+ ```
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "selenium-outlook-mail-notifier"
3
+ version = "0.1.0"
4
+ description = "Selenium-based Outlook email notifier"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "selenium>=4.49.0",
8
+ ]
9
+ readme = "README.md"
10
+
11
+ [project.optional-dependencies]
12
+ test = [
13
+ "pytest>=8.0.0",
14
+ ]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/Maschior/selenium-outlook-mail-notifier"
18
+
19
+ [build-system]
20
+ requires = ["setuptools>=68"]
21
+ build-backend = "setuptools.build_meta"
22
+
23
+ [tool.setuptools.packages.find]
24
+ include = ["selenium_outlook_mail_notifier*"]
@@ -0,0 +1,4 @@
1
+ from .exceptions import ElementNotFoundError
2
+ from .mailer import notify
3
+
4
+ __all__ = ["notify", "ElementNotFoundError"]
@@ -0,0 +1,11 @@
1
+ import logging
2
+
3
+ LOGGER = logging.getLogger(__name__)
4
+
5
+
6
+ class ElementNotFoundError(Exception):
7
+ """Raised when a Selenium element is not found in the DOM."""
8
+
9
+ def __init__(self, message: str = "Element not found"):
10
+ LOGGER.error(message)
11
+ super().__init__(message)
@@ -0,0 +1,141 @@
1
+ import logging
2
+ import os
3
+ import time
4
+
5
+ from selenium import webdriver
6
+ from selenium.webdriver.common.by import By
7
+ from selenium.common.exceptions import NoSuchElementException
8
+
9
+ from .exceptions import ElementNotFoundError
10
+
11
+ LOGGER = logging.getLogger(__name__)
12
+
13
+
14
+ def _input_email(driver: webdriver.Chrome, email: str) -> None:
15
+ LOGGER.info("Attempting to input email.")
16
+ try:
17
+ driver.find_element("id", "i0116").send_keys(email)
18
+ driver.find_element("id", "idSIButton9").click()
19
+ time.sleep(3)
20
+ except NoSuchElementException as exc:
21
+ driver.quit()
22
+ raise ElementNotFoundError(
23
+ "Email input field not found (id='i0116' or button id='idSIButton9')."
24
+ ) from exc
25
+
26
+
27
+ def _input_password(driver: webdriver.Chrome, password: str) -> None:
28
+ LOGGER.info("Attempting to input password.")
29
+ try:
30
+ driver.find_element("id", "i0118").send_keys(password)
31
+ driver.find_element("id", "idSIButton9").click()
32
+ time.sleep(3)
33
+ except NoSuchElementException as exc:
34
+ driver.quit()
35
+ raise ElementNotFoundError(
36
+ "Password input field not found (id='i0118' or button id='idSIButton9')."
37
+ ) from exc
38
+
39
+
40
+ def _open_new_mail_window(driver: webdriver.Chrome) -> None:
41
+ LOGGER.info("Attempting to open new mail window.")
42
+ try:
43
+ driver.find_element(By.XPATH, "//button[@label='New mail']").click()
44
+ time.sleep(3)
45
+ except NoSuchElementException as exc:
46
+ driver.quit()
47
+ raise ElementNotFoundError(
48
+ "New mail button not found (XPATH \"//button[@label='New mail']\")."
49
+ ) from exc
50
+
51
+
52
+ def _fill_and_send(
53
+ driver: webdriver.Chrome,
54
+ recipient: str,
55
+ subject: str,
56
+ body_text: str,
57
+ cc: list[str],
58
+ ) -> None:
59
+ LOGGER.info("Attempting to send email.")
60
+ try:
61
+ driver.find_element(By.XPATH, "//div[@aria-label='To']").send_keys(recipient)
62
+ except NoSuchElementException as exc:
63
+ driver.quit()
64
+ raise ElementNotFoundError(
65
+ "Recipient input field not found (XPATH \"//div[@aria-label='To']\")."
66
+ ) from exc
67
+
68
+ if cc:
69
+ try:
70
+ driver.find_element(By.XPATH, "//div[@aria-label='Cc']").send_keys(";".join(cc))
71
+ except NoSuchElementException as exc:
72
+ driver.quit()
73
+ raise ElementNotFoundError(
74
+ "CC input field not found (XPATH \"//div[@aria-label='Cc']\")."
75
+ ) from exc
76
+
77
+ try:
78
+ driver.find_element(By.XPATH, "//input[@aria-label='Subject']").send_keys(subject)
79
+ except NoSuchElementException as exc:
80
+ driver.quit()
81
+ raise ElementNotFoundError(
82
+ "Subject input field not found (XPATH \"//input[@aria-label='Subject']\")."
83
+ ) from exc
84
+
85
+ try:
86
+ driver.find_element(By.XPATH, "//div[@aria-label='Message body']").send_keys(body_text)
87
+ except NoSuchElementException as exc:
88
+ driver.quit()
89
+ raise ElementNotFoundError(
90
+ "Body input field not found (XPATH \"//div[@aria-label='Message body']\")."
91
+ ) from exc
92
+
93
+ try:
94
+ driver.find_element(By.XPATH, "//button[@aria-label='Send']").click()
95
+ except NoSuchElementException as exc:
96
+ driver.quit()
97
+ raise ElementNotFoundError(
98
+ "Send button not found (XPATH \"//button[@aria-label='Send']\")."
99
+ ) from exc
100
+
101
+
102
+ def notify(
103
+ subject: str,
104
+ body: str,
105
+ recipient: str | None = None,
106
+ cc: list[str] | None = None,
107
+ sender_email: str | None = None,
108
+ sender_password: str | None = None,
109
+ ) -> None:
110
+ """Log in to Outlook Web via Selenium and send one notification email.
111
+
112
+ Credentials/recipient fall back to the OUTLOOK_EMAIL, OUTLOOK_PASSWORD,
113
+ OUTLOOK_RECIPIENT and OUTLOOK_CC environment variables when not passed
114
+ explicitly, so callers never need to hardcode secrets.
115
+ """
116
+ sender_email = sender_email or os.environ["OUTLOOK_EMAIL"]
117
+ sender_password = sender_password or os.environ["OUTLOOK_PASSWORD"]
118
+ recipient = recipient or os.environ.get("OUTLOOK_RECIPIENT", sender_email)
119
+ if cc is None:
120
+ cc = [addr for addr in os.environ.get("OUTLOOK_CC", "").split(";") if addr]
121
+
122
+ start = time.time()
123
+ driver = webdriver.Chrome()
124
+ try:
125
+ LOGGER.info("Navigating to Outlook login page...")
126
+ driver.get("https://aka.ms/outlook")
127
+ time.sleep(5)
128
+
129
+ _input_email(driver, sender_email)
130
+ _input_password(driver, sender_password)
131
+ time.sleep(5) # wait for login to complete and the inbox to load
132
+
133
+ _open_new_mail_window(driver)
134
+ _fill_and_send(driver, recipient, subject, body, cc)
135
+ time.sleep(5) # wait for the email to be sent
136
+
137
+ LOGGER.info("Email probably sent successfully (sent items not checked).")
138
+ finally:
139
+ driver.quit()
140
+
141
+ LOGGER.info("notify() completed in %.2f seconds.", time.time() - start)
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: selenium-outlook-mail-notifier
3
+ Version: 0.1.0
4
+ Summary: Selenium-based Outlook email notifier
5
+ Project-URL: Homepage, https://github.com/Maschior/selenium-outlook-mail-notifier
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: selenium>=4.49.0
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest>=8.0.0; extra == "test"
12
+ Dynamic: license-file
13
+
14
+ # selenium-outlook-mail-notifier
15
+
16
+ [![Tests](https://github.com/Maschior/selenium-outlook-mail-notifier/actions/workflows/tests.yml/badge.svg)](https://github.com/Maschior/selenium-outlook-mail-notifier/actions/workflows/tests.yml)
17
+ [![PyPI](https://img.shields.io/pypi/v/selenium-outlook-mail-notifier.svg)](https://pypi.org/project/selenium-outlook-mail-notifier/)
18
+ [![Python versions](https://img.shields.io/pypi/pyversions/selenium-outlook-mail-notifier.svg)](https://pypi.org/project/selenium-outlook-mail-notifier/)
19
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
20
+
21
+ Send an email through Outlook Web (outlook.office.com) by driving a real
22
+ browser session with Selenium — useful when you don't have Microsoft Graph
23
+ API access but still need to trigger email notifications from a script.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install selenium-outlook-mail-notifier
29
+ ```
30
+
31
+ Requires Chrome and a matching chromedriver available on PATH (or managed by
32
+ Selenium Manager, which ships with Selenium 4.6+).
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ from selenium_outlook_mail_notifier import notify
38
+
39
+ notify(
40
+ subject="Something happened",
41
+ body="Body message",
42
+ recipient="someone@example.com", # optional, falls back to OUTLOOK_RECIPIENT
43
+ cc=["other@example.com"], # optional, falls back to OUTLOOK_CC
44
+ )
45
+ ```
46
+
47
+ Credentials are never passed as literals in code. `notify()` reads them from
48
+ environment variables unless you pass them explicitly:
49
+
50
+ | Variable | Required | Description |
51
+ |---------------------|----------|---------------------------------------|
52
+ | `OUTLOOK_EMAIL` | yes | Outlook account used to send the mail |
53
+ | `OUTLOOK_PASSWORD` | yes | Password for that account |
54
+ | `OUTLOOK_RECIPIENT` | no | Default recipient if not passed |
55
+ | `OUTLOOK_CC` | no | `;`-separated list of CC addresses |
56
+
57
+ ## Development
58
+
59
+ ```bash
60
+ pip install -e ".[test]"
61
+ pytest
62
+ ```
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ selenium_outlook_mail_notifier/__init__.py
5
+ selenium_outlook_mail_notifier/exceptions.py
6
+ selenium_outlook_mail_notifier/mailer.py
7
+ selenium_outlook_mail_notifier.egg-info/PKG-INFO
8
+ selenium_outlook_mail_notifier.egg-info/SOURCES.txt
9
+ selenium_outlook_mail_notifier.egg-info/dependency_links.txt
10
+ selenium_outlook_mail_notifier.egg-info/requires.txt
11
+ selenium_outlook_mail_notifier.egg-info/top_level.txt
12
+ tests/test_exceptions.py
13
+ tests/test_mailer.py
@@ -0,0 +1,4 @@
1
+ selenium>=4.49.0
2
+
3
+ [test]
4
+ pytest>=8.0.0
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+ import logging
2
+
3
+ import pytest
4
+
5
+ from selenium_outlook_mail_notifier import ElementNotFoundError
6
+
7
+
8
+ def test_default_message():
9
+ exc = ElementNotFoundError()
10
+ assert str(exc) == "Element not found"
11
+
12
+
13
+ def test_custom_message():
14
+ exc = ElementNotFoundError("Custom message")
15
+ assert str(exc) == "Custom message"
16
+
17
+
18
+ def test_is_exception():
19
+ assert issubclass(ElementNotFoundError, Exception)
20
+
21
+
22
+ def test_logs_error(caplog):
23
+ with caplog.at_level(logging.ERROR):
24
+ ElementNotFoundError("Boom")
25
+ assert "Boom" in caplog.text
26
+
27
+
28
+ def test_raisable():
29
+ with pytest.raises(ElementNotFoundError, match="oops"):
30
+ raise ElementNotFoundError("oops")
@@ -0,0 +1,184 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+ from selenium.common.exceptions import NoSuchElementException
5
+
6
+ from selenium_outlook_mail_notifier import ElementNotFoundError
7
+ from selenium_outlook_mail_notifier.mailer import (
8
+ _fill_and_send,
9
+ _input_email,
10
+ _input_password,
11
+ _open_new_mail_window,
12
+ notify,
13
+ )
14
+
15
+
16
+ @pytest.fixture
17
+ def driver():
18
+ return MagicMock()
19
+
20
+
21
+ @pytest.fixture(autouse=True)
22
+ def no_sleep():
23
+ with patch("selenium_outlook_mail_notifier.mailer.time.sleep"):
24
+ yield
25
+
26
+
27
+ class TestInputEmail:
28
+ def test_sends_email_and_clicks_next(self, driver):
29
+ _input_email(driver, "user@example.com")
30
+
31
+ driver.find_element.assert_any_call("id", "i0116")
32
+ driver.find_element.assert_any_call("id", "idSIButton9")
33
+ driver.find_element.return_value.send_keys.assert_called_with("user@example.com")
34
+
35
+ def test_missing_field_quits_driver_and_raises(self, driver):
36
+ driver.find_element.side_effect = NoSuchElementException()
37
+
38
+ with pytest.raises(ElementNotFoundError):
39
+ _input_email(driver, "user@example.com")
40
+
41
+ driver.quit.assert_called_once()
42
+
43
+
44
+ class TestInputPassword:
45
+ def test_sends_password_and_clicks_next(self, driver):
46
+ _input_password(driver, "hunter2")
47
+
48
+ driver.find_element.assert_any_call("id", "i0118")
49
+ driver.find_element.return_value.send_keys.assert_called_with("hunter2")
50
+
51
+ def test_missing_field_quits_driver_and_raises(self, driver):
52
+ driver.find_element.side_effect = NoSuchElementException()
53
+
54
+ with pytest.raises(ElementNotFoundError):
55
+ _input_password(driver, "hunter2")
56
+
57
+ driver.quit.assert_called_once()
58
+
59
+
60
+ class TestOpenNewMailWindow:
61
+ def test_clicks_new_mail_button(self, driver):
62
+ _open_new_mail_window(driver)
63
+
64
+ driver.find_element.return_value.click.assert_called_once()
65
+
66
+ def test_missing_button_quits_driver_and_raises(self, driver):
67
+ driver.find_element.side_effect = NoSuchElementException()
68
+
69
+ with pytest.raises(ElementNotFoundError):
70
+ _open_new_mail_window(driver)
71
+
72
+ driver.quit.assert_called_once()
73
+
74
+
75
+ class TestFillAndSend:
76
+ def test_fills_all_fields_and_sends(self, driver):
77
+ _fill_and_send(driver, "to@example.com", "Subject", "Body", ["cc@example.com"])
78
+
79
+ elements = driver.find_element.return_value
80
+ elements.send_keys.assert_any_call("to@example.com")
81
+ elements.send_keys.assert_any_call("cc@example.com")
82
+ elements.send_keys.assert_any_call("Subject")
83
+ elements.send_keys.assert_any_call("Body")
84
+ elements.click.assert_called_once()
85
+
86
+ def test_skips_cc_when_empty(self, driver):
87
+ _fill_and_send(driver, "to@example.com", "Subject", "Body", [])
88
+
89
+ calls = [c.args[0] for c in driver.find_element.call_args_list]
90
+ assert "//div[@aria-label='Cc']" not in calls
91
+
92
+ def test_missing_recipient_field_quits_driver_and_raises(self, driver):
93
+ driver.find_element.side_effect = NoSuchElementException()
94
+
95
+ with pytest.raises(ElementNotFoundError):
96
+ _fill_and_send(driver, "to@example.com", "Subject", "Body", [])
97
+
98
+ driver.quit.assert_called_once()
99
+
100
+ def test_missing_send_button_quits_driver_and_raises(self, driver):
101
+ ok = MagicMock()
102
+ driver.find_element.side_effect = [ok, ok, ok, NoSuchElementException()]
103
+
104
+ with pytest.raises(ElementNotFoundError):
105
+ _fill_and_send(driver, "to@example.com", "Subject", "Body", [])
106
+
107
+ driver.quit.assert_called_once()
108
+
109
+
110
+ class TestNotify:
111
+ @pytest.fixture(autouse=True)
112
+ def mocked_chrome(self, driver):
113
+ with patch("selenium_outlook_mail_notifier.mailer.webdriver.Chrome", return_value=driver):
114
+ yield driver
115
+
116
+ def test_uses_explicit_credentials_over_env(self, mocked_chrome, monkeypatch):
117
+ monkeypatch.setenv("OUTLOOK_EMAIL", "env@example.com")
118
+ monkeypatch.setenv("OUTLOOK_PASSWORD", "env-pass")
119
+
120
+ with patch("selenium_outlook_mail_notifier.mailer._input_email") as input_email, \
121
+ patch("selenium_outlook_mail_notifier.mailer._input_password") as input_password, \
122
+ patch("selenium_outlook_mail_notifier.mailer._open_new_mail_window"), \
123
+ patch("selenium_outlook_mail_notifier.mailer._fill_and_send"):
124
+ notify(
125
+ subject="Subject",
126
+ body="Body",
127
+ recipient="to@example.com",
128
+ sender_email="explicit@example.com",
129
+ sender_password="explicit-pass",
130
+ )
131
+
132
+ input_email.assert_called_once_with(mocked_chrome, "explicit@example.com")
133
+ input_password.assert_called_once_with(mocked_chrome, "explicit-pass")
134
+
135
+ def test_falls_back_to_env_credentials(self, mocked_chrome, monkeypatch):
136
+ monkeypatch.setenv("OUTLOOK_EMAIL", "env@example.com")
137
+ monkeypatch.setenv("OUTLOOK_PASSWORD", "env-pass")
138
+ monkeypatch.delenv("OUTLOOK_RECIPIENT", raising=False)
139
+ monkeypatch.delenv("OUTLOOK_CC", raising=False)
140
+
141
+ with patch("selenium_outlook_mail_notifier.mailer._input_email") as input_email, \
142
+ patch("selenium_outlook_mail_notifier.mailer._input_password") as input_password, \
143
+ patch("selenium_outlook_mail_notifier.mailer._open_new_mail_window"), \
144
+ patch("selenium_outlook_mail_notifier.mailer._fill_and_send") as fill_and_send:
145
+ notify(subject="Subject", body="Body")
146
+
147
+ input_email.assert_called_once_with(mocked_chrome, "env@example.com")
148
+ input_password.assert_called_once_with(mocked_chrome, "env-pass")
149
+ fill_and_send.assert_called_once_with(mocked_chrome, "env@example.com", "Subject", "Body", [])
150
+
151
+ def test_missing_email_env_raises_key_error(self, mocked_chrome, monkeypatch):
152
+ monkeypatch.delenv("OUTLOOK_EMAIL", raising=False)
153
+ monkeypatch.delenv("OUTLOOK_PASSWORD", raising=False)
154
+
155
+ with pytest.raises(KeyError):
156
+ notify(subject="Subject", body="Body")
157
+
158
+ def test_parses_cc_from_env(self, mocked_chrome, monkeypatch):
159
+ monkeypatch.setenv("OUTLOOK_EMAIL", "env@example.com")
160
+ monkeypatch.setenv("OUTLOOK_PASSWORD", "env-pass")
161
+ monkeypatch.setenv("OUTLOOK_CC", "a@example.com;b@example.com")
162
+
163
+ with patch("selenium_outlook_mail_notifier.mailer._input_email"), \
164
+ patch("selenium_outlook_mail_notifier.mailer._input_password"), \
165
+ patch("selenium_outlook_mail_notifier.mailer._open_new_mail_window"), \
166
+ patch("selenium_outlook_mail_notifier.mailer._fill_and_send") as fill_and_send:
167
+ notify(subject="Subject", body="Body", recipient="to@example.com")
168
+
169
+ fill_and_send.assert_called_once_with(
170
+ mocked_chrome, "to@example.com", "Subject", "Body", ["a@example.com", "b@example.com"]
171
+ )
172
+
173
+ def test_quits_driver_even_when_step_fails(self, mocked_chrome, monkeypatch):
174
+ monkeypatch.setenv("OUTLOOK_EMAIL", "env@example.com")
175
+ monkeypatch.setenv("OUTLOOK_PASSWORD", "env-pass")
176
+
177
+ with patch(
178
+ "selenium_outlook_mail_notifier.mailer._input_email",
179
+ side_effect=ElementNotFoundError("boom"),
180
+ ):
181
+ with pytest.raises(ElementNotFoundError):
182
+ notify(subject="Subject", body="Body", recipient="to@example.com")
183
+
184
+ mocked_chrome.quit.assert_called_once()