simple-module-mail 0.0.38__py3-none-any.whl
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.
- mail/__init__.py +1 -0
- mail/constants.py +24 -0
- mail/module.py +75 -0
- mail/py.typed +0 -0
- mail/runtime.py +63 -0
- mail/sender.py +94 -0
- mail/settings.py +30 -0
- mail/state.py +14 -0
- simple_module_mail-0.0.38.dist-info/METADATA +68 -0
- simple_module_mail-0.0.38.dist-info/RECORD +13 -0
- simple_module_mail-0.0.38.dist-info/WHEEL +4 -0
- simple_module_mail-0.0.38.dist-info/entry_points.txt +2 -0
- simple_module_mail-0.0.38.dist-info/licenses/LICENSE +19 -0
mail/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Mail module."""
|
mail/constants.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Every literal the mail module depends on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
MODULE_NAME = "Mail"
|
|
6
|
+
MODULE_PACKAGE = "mail"
|
|
7
|
+
|
|
8
|
+
# Dependency names must match the other modules' ``ModuleMeta.name``.
|
|
9
|
+
MODULE_SETTINGS = "Settings"
|
|
10
|
+
|
|
11
|
+
BACKEND_CONSOLE = "console"
|
|
12
|
+
BACKEND_SMTP = "smtp"
|
|
13
|
+
|
|
14
|
+
# On-demand health check; also drives the settings page's "Test connection".
|
|
15
|
+
CHECK_MAILER = "mail.mailer"
|
|
16
|
+
|
|
17
|
+
DEFAULT_FROM = "no-reply@localhost"
|
|
18
|
+
DEFAULT_SMTP_PORT = 587
|
|
19
|
+
MIN_PORT = 1
|
|
20
|
+
MAX_PORT = 65535
|
|
21
|
+
|
|
22
|
+
#: Seconds per SMTP operation (aiosmtplib defaults to 60). A send can run in a
|
|
23
|
+
#: request's after-commit hook, so a hung server must not hold it for a minute.
|
|
24
|
+
SMTP_TIMEOUT_SECONDS = 15.0
|
mail/module.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Mail module — the app's outgoing-mail transport, configured from the DB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from simple_module_core.health import HealthCheck, HealthRegistry
|
|
8
|
+
from simple_module_core.mail import install_mail_resolver, install_mail_sender
|
|
9
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
10
|
+
|
|
11
|
+
from mail import constants as c
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
from simple_module_core.events import EventBus
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MailModule(ModuleBase):
|
|
19
|
+
meta = ModuleMeta(
|
|
20
|
+
name=c.MODULE_NAME,
|
|
21
|
+
# ``Settings``: the runtime re-reads overrides through its SettingsStore.
|
|
22
|
+
depends_on=[c.MODULE_SETTINGS],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def register_settings(self, app: FastAPI) -> None:
|
|
26
|
+
from simple_module_core.module_settings import register_module_settings
|
|
27
|
+
|
|
28
|
+
from mail.settings import MailSettings
|
|
29
|
+
from mail.state import MailState
|
|
30
|
+
|
|
31
|
+
register_module_settings(
|
|
32
|
+
app, c.MODULE_PACKAGE, MailSettings, lambda s: MailState(settings=s)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def register_health_checks(self, registry: HealthRegistry) -> None:
|
|
36
|
+
from mail.runtime import check_smtp
|
|
37
|
+
|
|
38
|
+
registry.add(
|
|
39
|
+
HealthCheck(
|
|
40
|
+
name=c.CHECK_MAILER,
|
|
41
|
+
check=check_smtp,
|
|
42
|
+
# On demand only: this authenticates against the mail provider,
|
|
43
|
+
# which must not happen on a readiness-probe timer.
|
|
44
|
+
probe=False,
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
|
|
49
|
+
# Before the ``app is None`` return: the outbox worker builds modules
|
|
50
|
+
# without an app and still has to send durable mail.
|
|
51
|
+
from mail.runtime import resolve_sender_from_db
|
|
52
|
+
|
|
53
|
+
install_mail_resolver(resolve_sender_from_db)
|
|
54
|
+
if app is None:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
from settings.contracts.events import SettingsReloaded
|
|
58
|
+
|
|
59
|
+
from mail.runtime import install_app_sender
|
|
60
|
+
|
|
61
|
+
async def _reinstall(event: SettingsReloaded) -> None:
|
|
62
|
+
if event.package != c.MODULE_PACKAGE:
|
|
63
|
+
return
|
|
64
|
+
install_app_sender(app)
|
|
65
|
+
|
|
66
|
+
bus.subscribe(SettingsReloaded, _reinstall)
|
|
67
|
+
|
|
68
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
69
|
+
from mail.runtime import install_app_sender
|
|
70
|
+
|
|
71
|
+
install_app_sender(app)
|
|
72
|
+
|
|
73
|
+
async def on_shutdown(self, app: FastAPI) -> None:
|
|
74
|
+
install_mail_sender(None)
|
|
75
|
+
install_mail_resolver(None)
|
mail/py.typed
ADDED
|
File without changes
|
mail/runtime.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Resolver and health check: the parts that work without an app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from settings.service import SettingService
|
|
9
|
+
from settings.store import SettingsStore
|
|
10
|
+
from simple_module_core.environments import NON_PROD_ENVIRONMENTS
|
|
11
|
+
from simple_module_core.health import HealthCheckResult, HealthStatus
|
|
12
|
+
from simple_module_core.mail import MailSender, current_mail_sender, install_mail_sender
|
|
13
|
+
from simple_module_core.settings_hydrate import hydrate_settings
|
|
14
|
+
|
|
15
|
+
from mail import constants as c
|
|
16
|
+
from mail.sender import build_sender
|
|
17
|
+
from mail.settings import MailSettings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def resolve_sender_from_db(db: Any) -> MailSender:
|
|
21
|
+
"""Build a sender from the stored settings; used where none is installed.
|
|
22
|
+
|
|
23
|
+
The outbox worker builds modules without an app, so it has only a session.
|
|
24
|
+
"""
|
|
25
|
+
store = SettingsStore(SettingService(db))
|
|
26
|
+
settings = await hydrate_settings(MailSettings, store, c.MODULE_PACKAGE, lenient=True)
|
|
27
|
+
return build_sender(settings, log_bodies=_worker_is_non_prod())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _worker_is_non_prod() -> bool:
|
|
31
|
+
# No app (and so no host settings) in the worker: read the process's
|
|
32
|
+
# environment the way background_tasks' own checks do.
|
|
33
|
+
return os.environ.get("SM_ENVIRONMENT", "development") in NON_PROD_ENVIRONMENTS
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def install_app_sender(app: Any) -> None:
|
|
37
|
+
"""Install a sender built from *app*'s current mail settings.
|
|
38
|
+
|
|
39
|
+
Bodies are logged only in development or testing (host ``environment``).
|
|
40
|
+
"""
|
|
41
|
+
log_bodies = app.state.sm.settings.environment in NON_PROD_ENVIRONMENTS
|
|
42
|
+
install_mail_sender(build_sender(app.state.mail.settings, log_bodies=log_bodies))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def check_smtp() -> HealthCheckResult:
|
|
46
|
+
"""Verify the installed sender, re-read on every run.
|
|
47
|
+
|
|
48
|
+
Settings can change after boot, so a check pinned to the boot-time sender
|
|
49
|
+
would keep testing credentials the admin has replaced.
|
|
50
|
+
"""
|
|
51
|
+
sender = current_mail_sender()
|
|
52
|
+
verify = getattr(sender, "verify_connection", None)
|
|
53
|
+
if verify is None:
|
|
54
|
+
return HealthCheckResult(
|
|
55
|
+
status=HealthStatus.HEALTHY,
|
|
56
|
+
detail=f"{type(sender).__name__} needs no connection",
|
|
57
|
+
)
|
|
58
|
+
try:
|
|
59
|
+
await verify()
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
# The reason matters more than the traceback.
|
|
62
|
+
return HealthCheckResult(status=HealthStatus.UNHEALTHY, detail=str(exc))
|
|
63
|
+
return HealthCheckResult(status=HealthStatus.HEALTHY, detail="SMTP reachable")
|
mail/sender.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Mail senders and the settings -> sender mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
|
|
8
|
+
import aiosmtplib
|
|
9
|
+
from simple_module_core.mail import ConsoleMailSender, MailMessage, MailSender
|
|
10
|
+
|
|
11
|
+
from mail import constants as c
|
|
12
|
+
from mail.settings import MailSettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SmtpMailSender:
|
|
16
|
+
"""Delivers through an SMTP server with aiosmtplib."""
|
|
17
|
+
|
|
18
|
+
delivers_email = True
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
host: str,
|
|
23
|
+
port: int,
|
|
24
|
+
username: str,
|
|
25
|
+
password: str,
|
|
26
|
+
from_address: str,
|
|
27
|
+
use_tls: bool,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._host = host
|
|
30
|
+
self._port = port
|
|
31
|
+
self._username = username
|
|
32
|
+
self._password = password
|
|
33
|
+
self._from = from_address
|
|
34
|
+
self._use_tls = use_tls
|
|
35
|
+
|
|
36
|
+
async def send(self, message: MailMessage) -> None:
|
|
37
|
+
email = EmailMessage()
|
|
38
|
+
email["From"] = self._from
|
|
39
|
+
email["To"] = message.to
|
|
40
|
+
email["Subject"] = message.subject
|
|
41
|
+
email.set_content(message.text)
|
|
42
|
+
if message.html:
|
|
43
|
+
email.add_alternative(message.html, subtype="html")
|
|
44
|
+
|
|
45
|
+
await aiosmtplib.send(
|
|
46
|
+
email,
|
|
47
|
+
hostname=self._host,
|
|
48
|
+
port=self._port,
|
|
49
|
+
username=self._username or None,
|
|
50
|
+
password=self._password or None,
|
|
51
|
+
use_tls=self._use_tls,
|
|
52
|
+
timeout=c.SMTP_TIMEOUT_SECONDS,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def verify_connection(self) -> None:
|
|
56
|
+
"""Open an SMTP session and authenticate, then hang up.
|
|
57
|
+
|
|
58
|
+
Deliberately stops short of sending anything: an admin checking their
|
|
59
|
+
config should not put a stray message in someone's inbox. Raises
|
|
60
|
+
whatever aiosmtplib raises so the caller can show the real reason.
|
|
61
|
+
"""
|
|
62
|
+
client = aiosmtplib.SMTP(
|
|
63
|
+
hostname=self._host,
|
|
64
|
+
port=self._port,
|
|
65
|
+
use_tls=self._use_tls,
|
|
66
|
+
timeout=c.SMTP_TIMEOUT_SECONDS,
|
|
67
|
+
)
|
|
68
|
+
await client.connect()
|
|
69
|
+
try:
|
|
70
|
+
if self._username:
|
|
71
|
+
await client.login(self._username, self._password or "")
|
|
72
|
+
finally:
|
|
73
|
+
# A failure hanging up must not mask the login error being unwound.
|
|
74
|
+
with contextlib.suppress(Exception):
|
|
75
|
+
await client.quit()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_sender(settings: MailSettings, *, log_bodies: bool = True) -> MailSender:
|
|
79
|
+
"""``smtp`` -> :class:`SmtpMailSender`; ``console`` -> a logging sender.
|
|
80
|
+
|
|
81
|
+
*log_bodies* is false outside development and testing: a console backend
|
|
82
|
+
left in place in production must not write reset and invite tokens to the
|
|
83
|
+
log, so there it logs only recipient and subject.
|
|
84
|
+
"""
|
|
85
|
+
if settings.backend == c.BACKEND_SMTP:
|
|
86
|
+
return SmtpMailSender(
|
|
87
|
+
host=settings.smtp_host,
|
|
88
|
+
port=settings.smtp_port,
|
|
89
|
+
username=settings.smtp_username,
|
|
90
|
+
password=settings.smtp_password,
|
|
91
|
+
from_address=settings.smtp_from,
|
|
92
|
+
use_tls=settings.smtp_tls,
|
|
93
|
+
)
|
|
94
|
+
return ConsoleMailSender(include_body=log_bodies)
|
mail/settings.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Mail module settings — DB-backed via ``register_module_settings``.
|
|
2
|
+
|
|
3
|
+
``smtp_password`` is masked by the settings UI because its name matches the
|
|
4
|
+
secret-field pattern (``simple_module_core.secret_fields.is_secret_field``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
from pydantic_settings import SettingsConfigDict
|
|
13
|
+
from simple_module_core.settings_base import DbBackedSettings
|
|
14
|
+
|
|
15
|
+
from mail import constants as c
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MailSettings(DbBackedSettings):
|
|
19
|
+
"""Which transport sends mail, and the SMTP account it uses."""
|
|
20
|
+
|
|
21
|
+
# ``DbBackedSettings`` so the DB is the only source (GH #283).
|
|
22
|
+
model_config = SettingsConfigDict(extra="ignore")
|
|
23
|
+
|
|
24
|
+
backend: Literal["console", "smtp"] = c.BACKEND_CONSOLE
|
|
25
|
+
smtp_host: str = ""
|
|
26
|
+
smtp_port: int = Field(default=c.DEFAULT_SMTP_PORT, ge=c.MIN_PORT, le=c.MAX_PORT)
|
|
27
|
+
smtp_username: str = ""
|
|
28
|
+
smtp_password: str = ""
|
|
29
|
+
smtp_from: str = c.DEFAULT_FROM
|
|
30
|
+
smtp_tls: bool = True
|
mail/state.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Module-owned state attached to ``app.state.mail``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from mail.settings import MailSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class MailState:
|
|
12
|
+
"""``settings`` is reassigned in place on a hot reload, so stay mutable."""
|
|
13
|
+
|
|
14
|
+
settings: MailSettings
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: simple_module_mail
|
|
3
|
+
Version: 0.0.38
|
|
4
|
+
Summary: Outgoing mail (SMTP or console) for simple_module apps, configured from the DB-backed settings
|
|
5
|
+
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
7
|
+
Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: email,mail,simple-module,smtp
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: aiosmtplib>=3.0
|
|
15
|
+
Requires-Dist: simple-module-core==0.0.38
|
|
16
|
+
Requires-Dist: simple-module-settings==0.0.38
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# simple_module_mail
|
|
20
|
+
|
|
21
|
+
Outgoing mail for `simple_module` apps. The module owns the transport (SMTP via
|
|
22
|
+
`aiosmtplib`, or a console backend that logs each message) and its settings, and
|
|
23
|
+
installs a process-level sender that any module can use through
|
|
24
|
+
`simple_module_core.mail` without knowing whether mail is configured.
|
|
25
|
+
|
|
26
|
+
The default backend is `console`: messages are logged, nothing is delivered.
|
|
27
|
+
Switch to `smtp` and fill in the server details to deliver real email.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Add the package to your host and re-sync. Discovery picks it up from the
|
|
32
|
+
`simple_module` entry point, no host code changes:
|
|
33
|
+
|
|
34
|
+
```toml
|
|
35
|
+
# host/pyproject.toml
|
|
36
|
+
dependencies = ["simple_module_mail"]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv sync --all-packages
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
1. Sign in as an admin and open **Settings -> Modules -> Mail**.
|
|
46
|
+
2. Set `backend` to `smtp` and fill `smtp_host`, `smtp_port`, `smtp_username`,
|
|
47
|
+
`smtp_password` (masked in the UI), `smtp_from` and `smtp_tls`. Saving
|
|
48
|
+
rebuilds the sender live, no restart needed.
|
|
49
|
+
3. Use **Test connection** to run the `mail.mailer` health check. It logs in to
|
|
50
|
+
the server and hangs up without sending anything.
|
|
51
|
+
|
|
52
|
+
From code, send through the core interface:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from simple_module_core.mail import MailMessage, current_mail_sender
|
|
56
|
+
|
|
57
|
+
await current_mail_sender().send(
|
|
58
|
+
MailMessage(to="a@example.com", subject="Hello", text="Plain body")
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Code that may run in the outbox worker (no `app`) calls
|
|
63
|
+
`await resolve_mail_sender(db)` instead; the module installs a resolver that
|
|
64
|
+
builds the sender from the stored settings. `sender.delivers_email` is `False`
|
|
65
|
+
for the console backend, so screens can fall back to showing a link.
|
|
66
|
+
|
|
67
|
+
Settings stored by earlier versions as `users.mailer` / `users.smtp_*` are moved
|
|
68
|
+
to `mail.backend` / `mail.smtp_*` by a data migration.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
mail/__init__.py,sha256=Y-bRGfB1ShpdMEa3VnYILwicDfKX2FRg0U_s3-vA_CA,19
|
|
2
|
+
mail/constants.py,sha256=YSGPqHmbq_QdXUjVFWGp_yFkh4zTFkFKJWJlE-ZsEag,667
|
|
3
|
+
mail/module.py,sha256=syTNxf_AFrgIejUDZQbiOcMRVEyG7PI9NH13TeFZbrY,2485
|
|
4
|
+
mail/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
mail/runtime.py,sha256=rhsqUxAl4Avd_4TO18997PkZ-1wO85ghBPlH-UFKfjs,2461
|
|
6
|
+
mail/sender.py,sha256=uMSfJU82PF9GYabKMNU9VqjO-PQ3KTML8uYN2zhBFio,3050
|
|
7
|
+
mail/settings.py,sha256=EGEc5uxwr0kDIewA-EQFP-nL0TVHrB1n-ECF-_iN4Bw,985
|
|
8
|
+
mail/state.py,sha256=WIhsa4dsO9nk8HvL812m4D1AJgVJ9WnRsQpn19-ZLtE,306
|
|
9
|
+
simple_module_mail-0.0.38.dist-info/METADATA,sha256=_PMaL8ZIrgeA8Ae5AMMTA9iWjgQOzYQlW5gHD_4u5Bc,2591
|
|
10
|
+
simple_module_mail-0.0.38.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
simple_module_mail-0.0.38.dist-info/entry_points.txt,sha256=O3Bwcev1zoGXKR9CuwpTquAbn53euXpw6suXARrpyic,46
|
|
12
|
+
simple_module_mail-0.0.38.dist-info/licenses/LICENSE,sha256=UIp30ue1HZit7tMmSK0SS3swJBqOcLLnLJn5LY5YdNE,1036
|
|
13
|
+
simple_module_mail-0.0.38.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|