component email-gateway: first draft:
Initial implementation of the Email Gateway. This component uses XEP-0100 for registration. Upon registration and subsequent startups, a connection is made to registered IMAP services, and incoming emails (in `INBOX` mailboxes) are immediately forwarded as XMPP messages. In the opposite direction, an SMTP connection is established to send emails on incoming XMPP messages. rel 449
This commit is contained in:
parent
cf0363a3af
commit
73a08f5c91
3 changed files with 911 additions and 0 deletions
621
libervia/backend/plugins/plugin_comp_email_gateway/__init__.py
Normal file
621
libervia/backend/plugins/plugin_comp_email_gateway/__init__.py
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Libervia Email Gateway Component
|
||||
# Copyright (C) 2009-2024 Jérôme Poisson (goffi@goffi.org)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from email.header import decode_header
|
||||
from email.message import EmailMessage
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr, parseaddr
|
||||
from functools import partial
|
||||
import re
|
||||
from typing import Any, cast
|
||||
|
||||
from twisted.internet import defer, reactor
|
||||
from twisted.mail import smtp
|
||||
from twisted.words.protocols.jabber import jid
|
||||
from twisted.words.protocols.jabber.error import StanzaError
|
||||
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
|
||||
from twisted.words.xish import domish
|
||||
from wokkel import data_form, disco, iwokkel
|
||||
from zope.interface import implementer
|
||||
|
||||
from libervia.backend.core import exceptions
|
||||
from libervia.backend.core.constants import Const as C
|
||||
from libervia.backend.core.core_types import SatXMPPEntity
|
||||
from libervia.backend.core.i18n import D_, _
|
||||
from libervia.backend.core.log import getLogger
|
||||
from libervia.backend.memory.persistent import LazyPersistentBinaryDict
|
||||
from libervia.backend.memory.sqla import select
|
||||
from libervia.backend.memory.sqla_mapping import PrivateIndBin
|
||||
from libervia.backend.models.core import MessageData
|
||||
from libervia.backend.plugins.plugin_xep_0077 import XEP_0077
|
||||
from libervia.backend.plugins.plugin_xep_0106 import XEP_0106
|
||||
from libervia.backend.tools.utils import aio
|
||||
|
||||
from .models import Credentials, UserData
|
||||
from .imap import IMAPClientFactory
|
||||
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
IMPORT_NAME = "email-gateway"
|
||||
NAME = "Libervia Email Gateway"
|
||||
|
||||
PLUGIN_INFO = {
|
||||
C.PI_NAME: "Email Gateway Component",
|
||||
C.PI_IMPORT_NAME: IMPORT_NAME,
|
||||
C.PI_MODES: [C.PLUG_MODE_COMPONENT],
|
||||
C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
|
||||
C.PI_PROTOCOLS: [],
|
||||
C.PI_DEPENDENCIES: ["XEP-0077", "XEP-0106"],
|
||||
C.PI_RECOMMENDATIONS: [],
|
||||
C.PI_MAIN: "EmailGatewayComponent",
|
||||
C.PI_HANDLER: C.BOOL_TRUE,
|
||||
C.PI_DESCRIPTION: D_(
|
||||
"Gateway to handle email. Usual emails are handled as message, while mailing "
|
||||
"lists are converted to pubsub blogs."
|
||||
),
|
||||
}
|
||||
|
||||
CONF_SECTION = f"component {IMPORT_NAME}"
|
||||
PREFIX_KEY_CREDENTIALS = "CREDENTIALS_"
|
||||
KEY_CREDENTIALS = f"{PREFIX_KEY_CREDENTIALS}{{from_jid}}"
|
||||
|
||||
email_pattern = re.compile(r"[^@]+@[^@]+\.[^@]+")
|
||||
|
||||
|
||||
class EmailGatewayComponent:
|
||||
IMPORT_NAME = IMPORT_NAME
|
||||
verbose = 0
|
||||
|
||||
def __init__(self, host):
|
||||
self.host = host
|
||||
self.client: SatXMPPEntity | None = None
|
||||
self.initalized = False
|
||||
self.storage: LazyPersistentBinaryDict | None = None
|
||||
self._iq_register = cast(XEP_0077, host.plugins["XEP-0077"])
|
||||
self._iq_register.register_handler(
|
||||
self._on_registration_form, self._on_registration_submit
|
||||
)
|
||||
self._e = cast(XEP_0106, host.plugins["XEP-0106"])
|
||||
# TODO: For the moment, all credentials are kept in cache; we should only keep the
|
||||
# X latest.
|
||||
self.users_data: dict[jid.JID, UserData] = {}
|
||||
host.trigger.add_with_check(
|
||||
"message_received", self, self._message_received_trigger, priority=-1000
|
||||
)
|
||||
|
||||
async def _init(self) -> None:
|
||||
"""Initialisation done after profile is connected"""
|
||||
assert self.client is not None
|
||||
self.client.identities.append(disco.DiscoIdentity("gateway", "smtp", NAME))
|
||||
self.storage = LazyPersistentBinaryDict(IMPORT_NAME, self.client.profile)
|
||||
await self.connect_registered_users()
|
||||
|
||||
@aio
|
||||
async def get_registered_users(self) -> dict[jid.JID, Credentials]:
|
||||
"""Retrieve credentials for all registered users
|
||||
|
||||
@return: a mapping from user JID to credentials data.
|
||||
"""
|
||||
assert self.client is not None
|
||||
profile_id = self.host.memory.storage.profiles[self.client.profile]
|
||||
async with self.host.memory.storage.session() as session:
|
||||
query = select(PrivateIndBin).where(
|
||||
PrivateIndBin.profile_id == profile_id,
|
||||
PrivateIndBin.namespace == IMPORT_NAME,
|
||||
PrivateIndBin.key.startswith(PREFIX_KEY_CREDENTIALS),
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return {
|
||||
jid.JID(p.key[len(PREFIX_KEY_CREDENTIALS) :]): p.value
|
||||
for p in result.scalars()
|
||||
}
|
||||
|
||||
async def connect_registered_users(self) -> None:
|
||||
"""Connected users already registered to the gateway."""
|
||||
registered_data = await self.get_registered_users()
|
||||
for user_jid, credentials in registered_data.items():
|
||||
user_data = self.users_data[user_jid] = UserData(credentials=credentials)
|
||||
if not credentials["imap_success"]:
|
||||
log.warning(
|
||||
f"Ignoring unsuccessful IMAP credentials of {user_jid}. This user "
|
||||
"won't receive message from this gateway."
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await self.connect_imap(user_jid, user_data)
|
||||
except Exception as e:
|
||||
log.warning(f"Can't connect {user_jid} to IMAP: {e}.")
|
||||
else:
|
||||
log.debug(f"Connection to IMAP server successful for {user_jid}.")
|
||||
|
||||
def get_handler(self, __) -> XMPPHandler:
|
||||
return EmailGatewayHandler()
|
||||
|
||||
async def profile_connecting(self, client: SatXMPPEntity) -> None:
|
||||
self.client = client
|
||||
if not self.initalized:
|
||||
await self._init()
|
||||
self.initalized = True
|
||||
|
||||
def _message_received_trigger(
|
||||
self,
|
||||
client: SatXMPPEntity,
|
||||
message_elt: domish.Element,
|
||||
post_treat: defer.Deferred,
|
||||
) -> bool:
|
||||
"""add the gateway workflow on post treatment"""
|
||||
if client != self.client:
|
||||
return True
|
||||
post_treat.addCallback(
|
||||
lambda mess_data: defer.ensureDeferred(
|
||||
self.on_message(client, mess_data, message_elt)
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
async def on_message(
|
||||
self, client: SatXMPPEntity, mess_data: MessageData, message_elt: domish.Element
|
||||
) -> dict:
|
||||
"""Called once message has been parsed
|
||||
|
||||
@param client: Client session.
|
||||
@param mess_data: Message data.
|
||||
@return: Message data.
|
||||
"""
|
||||
if client != self.client:
|
||||
return mess_data
|
||||
from_jid = mess_data["from"].userhostJID()
|
||||
if mess_data["type"] not in ("chat", "normal"):
|
||||
log.warning(f"ignoring message with unexpected type: {mess_data}")
|
||||
return mess_data
|
||||
if not client.is_local(from_jid):
|
||||
log.warning(f"ignoring non local message: {mess_data}")
|
||||
return mess_data
|
||||
if not mess_data["to"].user:
|
||||
log.warning(f"ignoring message addressed to gateway itself: {mess_data}")
|
||||
return mess_data
|
||||
|
||||
try:
|
||||
to_email = self._e.unescape(mess_data["to"].user)
|
||||
except ValueError:
|
||||
raise exceptions.DataError(
|
||||
f'Invalid "to" JID, can\'t send message: {message_elt.toXml()}.'
|
||||
)
|
||||
|
||||
try:
|
||||
body_lang, body = next(iter(mess_data["message"].items()))
|
||||
except (KeyError, StopIteration):
|
||||
log.warning(f"No body found: {mess_data}")
|
||||
body_lang, body = "", ""
|
||||
try:
|
||||
subject_lang, subject = next(iter(mess_data["subject"].items()))
|
||||
except (KeyError, StopIteration):
|
||||
subject_lang, subject = "", None
|
||||
|
||||
if not body and not subject:
|
||||
log.warning(f"Ignoring empty message: {mess_data}")
|
||||
return mess_data
|
||||
|
||||
try:
|
||||
await self.send_email(
|
||||
from_jid=from_jid,
|
||||
to_email=to_email,
|
||||
body=body,
|
||||
subject=subject,
|
||||
)
|
||||
except exceptions.UnknownEntityError:
|
||||
log.warning(f"Can't send message, user {from_jid} is not registered.")
|
||||
message_error_elt = StanzaError(
|
||||
"subscription-required",
|
||||
text="User need to register to the gateway before sending emails.",
|
||||
).toResponse(message_elt)
|
||||
await client.a_send(message_error_elt)
|
||||
|
||||
raise exceptions.CancelError("User not registered.")
|
||||
|
||||
return mess_data
|
||||
|
||||
async def send_email(
|
||||
self,
|
||||
from_jid: jid.JID,
|
||||
to_email: str,
|
||||
body: str,
|
||||
subject: str | None,
|
||||
) -> None:
|
||||
"""Send an email using sender credentials.
|
||||
|
||||
Credentials will be retrieve from cache, or database.
|
||||
|
||||
@param from_jid: Bare JID of the sender.
|
||||
@param to_email: Email address of the destinee.
|
||||
@param body: Body of the email.
|
||||
@param subject: Subject of the email.
|
||||
|
||||
@raise exceptions.UnknownEntityError: Credentials for "from_jid" can't be found.
|
||||
"""
|
||||
# We need a bare jid.
|
||||
assert self.storage is not None
|
||||
assert not from_jid.resource
|
||||
try:
|
||||
user_data = self.users_data[from_jid]
|
||||
except KeyError:
|
||||
key = KEY_CREDENTIALS.format(from_jid=from_jid)
|
||||
credentials = await self.storage.get(key)
|
||||
if credentials is None:
|
||||
raise exceptions.UnknownEntityError(
|
||||
f"No credentials found for {from_jid}."
|
||||
)
|
||||
self.users_data[from_jid] = UserData(credentials)
|
||||
else:
|
||||
credentials = user_data.credentials
|
||||
|
||||
msg = MIMEText(body, "plain", "UTF-8")
|
||||
if subject is not None:
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = formataddr(
|
||||
(credentials["user_name"] or None, credentials["user_email"])
|
||||
)
|
||||
msg["To"] = to_email
|
||||
|
||||
sender_domain = credentials["user_email"].split("@", 1)[-1]
|
||||
|
||||
await smtp.sendmail(
|
||||
credentials["smtp_host"].encode(),
|
||||
credentials["user_email"].encode(),
|
||||
[to_email.encode()],
|
||||
msg.as_bytes(),
|
||||
senderDomainName=sender_domain,
|
||||
port=int(credentials["smtp_port"]),
|
||||
username=credentials["smtp_username"].encode(),
|
||||
password=credentials["smtp_password"].encode(),
|
||||
requireAuthentication=True,
|
||||
# TODO: only STARTTLS is supported right now, implicit TLS should be supported
|
||||
# too.
|
||||
requireTransportSecurity=True,
|
||||
)
|
||||
|
||||
async def _on_registration_form(
|
||||
self, client: SatXMPPEntity, iq_elt: domish.Element
|
||||
) -> tuple[bool, data_form.Form] | None:
|
||||
if client != self.client:
|
||||
return
|
||||
assert self.storage is not None
|
||||
from_jid = jid.JID(iq_elt["from"])
|
||||
key = KEY_CREDENTIALS.format(from_jid=from_jid.userhost())
|
||||
credentials = await self.storage.get(key) or {}
|
||||
|
||||
form = data_form.Form(formType="form", title="IMAP/SMTP Credentials")
|
||||
|
||||
# Add instructions
|
||||
form.instructions = [
|
||||
D_(
|
||||
"Please provide your IMAP and SMTP credentials to configure the "
|
||||
"connection."
|
||||
)
|
||||
]
|
||||
|
||||
# Add identity fields
|
||||
form.addField(
|
||||
data_form.Field(
|
||||
fieldType="text-single",
|
||||
var="user_name",
|
||||
label="User Name",
|
||||
desc=D_('The display name to use in the "From" field of sent emails.'),
|
||||
value=credentials.get("user_name"),
|
||||
required=True,
|
||||
)
|
||||
)
|
||||
|
||||
form.addField(
|
||||
data_form.Field(
|
||||
fieldType="text-single",
|
||||
var="user_email",
|
||||
label="User Email",
|
||||
desc=D_('The email address to use in the "From" field of sent emails.'),
|
||||
value=credentials.get("user_email"),
|
||||
required=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Add fields for IMAP credentials
|
||||
form.addField(
|
||||
data_form.Field(
|
||||
fieldType="text-single",
|
||||
var="imap_host",
|
||||
label="IMAP Host",
|
||||
desc=D_("IMAP server hostname or IP address"),
|
||||
value=credentials.get("imap_host"),
|
||||
required=True,
|
||||
)
|
||||
)
|
||||
form.addField(
|
||||
data_form.Field(
|
||||
fieldType="text-single",
|
||||
var="imap_port",
|
||||
label="IMAP Port",
|
||||
desc=D_("IMAP server port (default: 993)"),
|
||||
value=credentials.get("imap_port", "993"),
|
||||
)
|
||||
)
|
||||
form.addField(
|
||||
data_form.Field(
|
||||
fieldType="text-single",
|
||||
var="imap_username",
|
||||
label="IMAP Username",
|
||||
desc=D_("Username for IMAP authentication"),
|
||||
value=credentials.get("imap_username"),
|
||||
required=True,
|
||||
)
|
||||
)
|
||||
form.addField(
|
||||
data_form.Field(
|
||||
fieldType="text-private",
|
||||
var="imap_password",
|
||||
label="IMAP Password",
|
||||
desc=D_("Password for IMAP authentication"),
|
||||
value=credentials.get("imap_password"),
|
||||
required=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Add fields for SMTP credentials
|
||||
form.addField(
|
||||
data_form.Field(
|
||||
fieldType="text-single",
|
||||
var="smtp_host",
|
||||
label="SMTP Host",
|
||||
desc=D_("SMTP server hostname or IP address"),
|
||||
value=credentials.get("smtp_host"),
|
||||
required=True,
|
||||
)
|
||||
)
|
||||
form.addField(
|
||||
data_form.Field(
|
||||
fieldType="text-single",
|
||||
var="smtp_port",
|
||||
label="SMTP Port",
|
||||
desc=D_("SMTP server port (default: 587)"),
|
||||
value=credentials.get("smtp_port", "587"),
|
||||
)
|
||||
)
|
||||
form.addField(
|
||||
data_form.Field(
|
||||
fieldType="text-single",
|
||||
var="smtp_username",
|
||||
label="SMTP Username",
|
||||
desc=D_("Username for SMTP authentication"),
|
||||
value=credentials.get("smtp_username"),
|
||||
required=True,
|
||||
)
|
||||
)
|
||||
form.addField(
|
||||
data_form.Field(
|
||||
fieldType="text-private",
|
||||
var="smtp_password",
|
||||
label="SMTP Password",
|
||||
desc=D_("Password for SMTP authentication"),
|
||||
value=credentials.get("smtp_password"),
|
||||
required=True,
|
||||
)
|
||||
)
|
||||
|
||||
return bool(credentials), form
|
||||
|
||||
def validate_field(
|
||||
self,
|
||||
form: data_form.Form,
|
||||
key: str,
|
||||
field_type: str,
|
||||
min_value: int | None = None,
|
||||
max_value: int | None = None,
|
||||
default: str | int | None = None,
|
||||
) -> None:
|
||||
"""Validate a single field.
|
||||
|
||||
@param form: The form containing the fields.
|
||||
@param key: The key of the field to validate.
|
||||
@param field_type: The expected type of the field value.
|
||||
@param min_value: Optional minimum value for integer fields.
|
||||
@param max_value: Optional maximum value for integer fields.
|
||||
@param default: Default value to use if the field is missing.
|
||||
@raise StanzaError: If the field value is invalid or missing.
|
||||
"""
|
||||
field = form.fields.get(key)
|
||||
if field is None:
|
||||
if default is None:
|
||||
raise StanzaError("bad-request", text=f"{key} is required")
|
||||
field = data_form.Field(var=key, value=str(default))
|
||||
form.addField(field)
|
||||
|
||||
value = field.value
|
||||
if field_type == "int":
|
||||
try:
|
||||
value = int(value)
|
||||
if (min_value is not None and value < min_value) or (
|
||||
max_value is not None and value > max_value
|
||||
):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
raise StanzaError("bad-request", text=f"Invalid value for {key}: {value}")
|
||||
elif field_type == "str":
|
||||
if not isinstance(value, str):
|
||||
raise StanzaError("bad-request", text=f"Invalid value for {key}: {value}")
|
||||
|
||||
# Basic email validation for user_email field
|
||||
if key == "user_email":
|
||||
# XXX: This is a minimal check. A complete email validation is notoriously
|
||||
# difficult.
|
||||
if not email_pattern.match(value):
|
||||
raise StanzaError(
|
||||
"bad-request", text=f"Invalid email address: {value}"
|
||||
)
|
||||
|
||||
def validate_imap_smtp_form(self, submit_form: data_form.Form) -> None:
|
||||
"""Validate the submitted IMAP/SMTP credentials form.
|
||||
|
||||
@param submit_form: The submitted form containing IMAP/SMTP credentials.
|
||||
@raise StanzaError: If any of the values are invalid.
|
||||
"""
|
||||
# Validate identity fields
|
||||
self.validate_field(submit_form, "user_name", "str")
|
||||
self.validate_field(submit_form, "user_email", "str")
|
||||
|
||||
# Validate IMAP fields
|
||||
self.validate_field(submit_form, "imap_host", "str")
|
||||
self.validate_field(
|
||||
submit_form, "imap_port", "int", min_value=1, max_value=65535, default=993
|
||||
)
|
||||
self.validate_field(submit_form, "imap_username", "str")
|
||||
self.validate_field(submit_form, "imap_password", "str")
|
||||
|
||||
# Validate SMTP fields
|
||||
self.validate_field(submit_form, "smtp_host", "str")
|
||||
self.validate_field(
|
||||
submit_form, "smtp_port", "int", min_value=1, max_value=65535, default=587
|
||||
)
|
||||
self.validate_field(submit_form, "smtp_username", "str")
|
||||
self.validate_field(submit_form, "smtp_password", "str")
|
||||
|
||||
async def on_new_email(self, to_jid: jid.JID, email: EmailMessage) -> None:
|
||||
"""Called when a new message has been received.
|
||||
|
||||
@param to_jid: JID of the recipient.
|
||||
@param email: Parsed email.
|
||||
"""
|
||||
assert self.client is not None
|
||||
name, email_addr = parseaddr(email["from"])
|
||||
email_addr = email_addr.lower()
|
||||
from_jid = jid.JID(None, (self._e.escape(email_addr), self.client.jid.host, None))
|
||||
|
||||
# Get the email body
|
||||
body_mime = email.get_body(("plain",))
|
||||
if body_mime is not None:
|
||||
charset = body_mime.get_content_charset() or "utf-8"
|
||||
body = body_mime.get_payload(decode=True).decode(charset, errors="replace")
|
||||
else:
|
||||
log.warning(f"No body found in email:\n{email}")
|
||||
body = ""
|
||||
|
||||
# Decode the subject
|
||||
subject = email.get("subject")
|
||||
if subject:
|
||||
decoded_subject = decode_header(subject)
|
||||
subject = "".join(
|
||||
[
|
||||
part.decode(encoding or "utf-8") if isinstance(part, bytes) else part
|
||||
for part, encoding in decoded_subject
|
||||
]
|
||||
).strip()
|
||||
else:
|
||||
subject = None
|
||||
|
||||
client = self.client.get_virtual_client(from_jid)
|
||||
await client.sendMessage(to_jid, {"": body}, {"": subject} if subject else None)
|
||||
|
||||
async def connect_imap(self, from_jid: jid.JID, user_data: UserData) -> None:
|
||||
"""Connect to IMAP service.
|
||||
|
||||
[self.on_new_email] will be used as callback on new messages.
|
||||
|
||||
@param from_jid: JID of the user associated with given credentials.
|
||||
@param credentials: Email credentials.
|
||||
"""
|
||||
credentials = user_data.credentials
|
||||
|
||||
connected = defer.Deferred()
|
||||
factory = IMAPClientFactory(
|
||||
user_data,
|
||||
partial(self.on_new_email, from_jid.userhostJID()),
|
||||
connected,
|
||||
)
|
||||
reactor.connectTCP(
|
||||
credentials["imap_host"], int(credentials["imap_port"]), factory
|
||||
)
|
||||
await connected
|
||||
|
||||
async def _on_registration_submit(
|
||||
self,
|
||||
client: SatXMPPEntity,
|
||||
iq_elt: domish.Element,
|
||||
submit_form: data_form.Form | None,
|
||||
) -> bool | None:
|
||||
"""Handle registration submit request.
|
||||
|
||||
Submit form is validated, and credentials are stored.
|
||||
@param client: client session.
|
||||
iq_elt: IQ stanza of the submission request.
|
||||
submit_form: submit form.
|
||||
@return: True if successful.
|
||||
None if the callback is not relevant for this request.
|
||||
"""
|
||||
if client != self.client:
|
||||
return
|
||||
assert self.storage is not None
|
||||
from_jid = jid.JID(iq_elt["from"]).userhostJID()
|
||||
|
||||
if submit_form is None:
|
||||
# This is an unregistration request.
|
||||
try:
|
||||
user_data = self.users_data[from_jid]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if user_data.imap_client is not None:
|
||||
try:
|
||||
await user_data.imap_client.logout()
|
||||
except Exception:
|
||||
log.exception(f"Can't log out {from_jid} from IMAP server.")
|
||||
key = KEY_CREDENTIALS.format(from_jid=from_jid)
|
||||
await self.storage.adel(key)
|
||||
log.info(f"{from_jid} unregistered from this gateway.")
|
||||
return True
|
||||
|
||||
self.validate_imap_smtp_form(submit_form)
|
||||
credentials = {key: field.value for key, field in submit_form.fields.items()}
|
||||
user_data = self.users_data.get(from_jid)
|
||||
if user_data is None:
|
||||
# The user is not in cache, we cache current credentials.
|
||||
user_data = self.users_data[from_jid] = UserData(credentials=credentials)
|
||||
else:
|
||||
# The user is known, we update credentials.
|
||||
user_data.credentials = credentials
|
||||
key = KEY_CREDENTIALS.format(from_jid=from_jid)
|
||||
try:
|
||||
await self.connect_imap(from_jid, user_data)
|
||||
except Exception as e:
|
||||
log.warning(f"Can't connect to IMAP server for {from_jid}")
|
||||
credentials["imap_success"] = False
|
||||
await self.storage.aset(key, credentials)
|
||||
raise e
|
||||
else:
|
||||
log.debug(f"Connection successful to IMAP server for {from_jid}")
|
||||
credentials["imap_success"] = True
|
||||
await self.storage.aset(key, credentials)
|
||||
return True
|
||||
|
||||
|
||||
@implementer(iwokkel.IDisco)
|
||||
class EmailGatewayHandler(XMPPHandler):
|
||||
|
||||
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
|
||||
return []
|
||||
|
||||
def getDiscoItems(self, requestor, target, nodeIdentifier=""):
|
||||
return []
|
||||
260
libervia/backend/plugins/plugin_comp_email_gateway/imap.py
Normal file
260
libervia/backend/plugins/plugin_comp_email_gateway/imap.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Libervia Email Gateway Component
|
||||
# Copyright (C) 2009-2024 Jérôme Poisson (goffi@goffi.org)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from email.message import EmailMessage
|
||||
from email.parser import BytesParser, Parser
|
||||
from email import policy
|
||||
from typing import Callable, cast
|
||||
from twisted.internet import defer, protocol, reactor
|
||||
from twisted.internet.base import DelayedCall
|
||||
from twisted.mail import imap4
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
from libervia.backend.core import exceptions
|
||||
from libervia.backend.core.i18n import _
|
||||
from libervia.backend.core.log import getLogger
|
||||
from .models import UserData
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
class IMAPClient(imap4.IMAP4Client):
|
||||
_idling = False
|
||||
_idle_timer: DelayedCall | None = None
|
||||
|
||||
def __init__(self, connected: defer.Deferred, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._connected = connected
|
||||
|
||||
def serverGreeting(self, caps: dict) -> None:
|
||||
"""Handle the server greeting and capabilities.
|
||||
|
||||
@param caps: Server capabilities.
|
||||
"""
|
||||
defer.ensureDeferred(self.on_server_greeting(caps))
|
||||
|
||||
async def on_server_greeting(self, caps: dict) -> None:
|
||||
"""Async method called when server greeting is received.
|
||||
|
||||
@param caps: Server capabilities.
|
||||
"""
|
||||
self.server_capabilities = caps
|
||||
try:
|
||||
await self.authenticate(self.factory.password.encode())
|
||||
except Exception as e:
|
||||
log.warning(f"Can't authenticate: {e}")
|
||||
self._connected.errback(
|
||||
exceptions.PasswordError("Authentication error for IMAP server.")
|
||||
)
|
||||
return
|
||||
log.debug("Authenticated.")
|
||||
self._connected.callback(None)
|
||||
if b"IDLE" in caps:
|
||||
# We use "examine" for read-only access for now, will probably change in the
|
||||
# future.
|
||||
await self.examine(b"INBOX")
|
||||
log.debug("Activating IDLE mode")
|
||||
await self.idle()
|
||||
else:
|
||||
log.warning(
|
||||
f'"IDLE" mode is not supported by your server, this gateways needs a '
|
||||
"server supporting this mode."
|
||||
)
|
||||
return
|
||||
|
||||
async def idle(self) -> None:
|
||||
"""Enter the IDLE mode to receive real-time updates from the server."""
|
||||
if self._idling:
|
||||
# We are already in idle state.
|
||||
return
|
||||
self._idling = True
|
||||
self._idle_timer = reactor.callLater(29 * 60, self.on_idle_timeout)
|
||||
await self.sendCommand(
|
||||
imap4.Command(
|
||||
b"IDLE",
|
||||
continuation=lambda *a, **kw: log.debug(f"continuation: {a=} {kw=}"),
|
||||
)
|
||||
)
|
||||
|
||||
def idle_exit(self) -> None:
|
||||
"""Exit the IDLE mode."""
|
||||
assert self._idling
|
||||
assert self._idle_timer is not None
|
||||
if not self._idle_timer.called:
|
||||
self._idle_timer.cancel()
|
||||
self._idle_timer = None
|
||||
# Send DONE command to exit IDLE mode.
|
||||
self.sendLine(b"DONE")
|
||||
self._idling = False
|
||||
log.debug("IDLE mode terminated")
|
||||
|
||||
def on_idle_timeout(self):
|
||||
"""Called when IDLE mode timeout is reached."""
|
||||
if self._idling:
|
||||
# We've reached 29 min of IDLE mode, we restart it as recommended in the
|
||||
# specifications.
|
||||
self.idle_exit()
|
||||
defer.ensureDeferred(self.idle())
|
||||
|
||||
def newMessages(self, exists: int | None, recent: int | None):
|
||||
"""Called when new messages are received.
|
||||
|
||||
@param exists: Number of existing messages.
|
||||
@param recent: Number of recent messages.
|
||||
"""
|
||||
defer.ensureDeferred(self.on_new_emails(exists, recent))
|
||||
|
||||
async def on_new_emails(self, exists: int | None, recent: int | None) -> None:
|
||||
"""Async method called when new messages are received.
|
||||
|
||||
@param exists: Number of existing messages.
|
||||
@param recent: Number of recent messages.
|
||||
"""
|
||||
log.debug(f"New messages: {exists}, Recent messages: {recent}")
|
||||
log.debug("Retrieving last message.")
|
||||
self.idle_exit()
|
||||
mess_data = await self.fetchMessage("*")
|
||||
for message in mess_data.values():
|
||||
try:
|
||||
content = message["RFC822"]
|
||||
except KeyError:
|
||||
log.warning(f"Can't find content for {message}.")
|
||||
continue
|
||||
else:
|
||||
if isinstance(content, str):
|
||||
parser = Parser(policy=policy.default)
|
||||
parser_method = parser.parsestr
|
||||
elif isinstance(content, bytes):
|
||||
parser = BytesParser(policy=policy.default)
|
||||
parser_method = parser.parsebytes
|
||||
else:
|
||||
log.error(f"Invalid content: {content}")
|
||||
continue
|
||||
try:
|
||||
parsed = parser_method(content)
|
||||
except Exception as e:
|
||||
log.warning(f"Can't parse content of email: {e}")
|
||||
continue
|
||||
else:
|
||||
assert self.factory is not None
|
||||
factory = cast(IMAPClientFactory, self.factory)
|
||||
await factory.on_new_email(parsed)
|
||||
|
||||
defer.ensureDeferred(self.idle())
|
||||
|
||||
def connectionLost(self, reason: Failure) -> None:
|
||||
"""Called when the connection is lost.
|
||||
|
||||
@param reason: The reason for the lost connection.
|
||||
"""
|
||||
log.debug(f"connectionLost {reason=}")
|
||||
if not self._connected.called:
|
||||
self._connected.errback(reason)
|
||||
super().connectionLost(reason)
|
||||
|
||||
def lineReceived(self, line: bytes) -> None:
|
||||
"""Called when a line is received from the server.
|
||||
|
||||
@param line: The received line.
|
||||
"""
|
||||
if self._idling:
|
||||
if line == b"* OK Still here":
|
||||
pass
|
||||
elif line == b"+ idling":
|
||||
pass
|
||||
elif line.startswith(b"* "):
|
||||
# Handle unsolicited responses during IDLE
|
||||
self._extraInfo([imap4.parseNestedParens(line[2:])])
|
||||
else:
|
||||
log.warning(f"Unexpected line received: {line!r}")
|
||||
|
||||
return
|
||||
|
||||
return super().lineReceived(line)
|
||||
|
||||
def sendCommand(self, cmd: imap4.Command) -> defer.Deferred:
|
||||
"""Send a command to the server.
|
||||
|
||||
This method is overriden to stop and restart IDLE mode when a command is received.
|
||||
|
||||
@param cmd: The command to send.
|
||||
@return: A deferred that fires when the command is sent.
|
||||
"""
|
||||
if self._idling and cmd.command != b"IDLE":
|
||||
self.idle_exit()
|
||||
d = super().sendCommand(cmd)
|
||||
|
||||
def restart_idle_mode(ret):
|
||||
defer.ensureDeferred(self.idle())
|
||||
return ret
|
||||
|
||||
d.addCallback(restart_idle_mode)
|
||||
return d
|
||||
else:
|
||||
return super().sendCommand(cmd)
|
||||
|
||||
|
||||
class IMAPClientFactory(protocol.ClientFactory):
|
||||
protocol = IMAPClient
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_data: UserData,
|
||||
on_new_email: Callable[[EmailMessage], None],
|
||||
connected: defer.Deferred,
|
||||
) -> None:
|
||||
"""Initialize the IMAP client factory.
|
||||
|
||||
@param username: The username to use for authentication.
|
||||
@param password: The password to use for authentication.
|
||||
"""
|
||||
credentials = user_data.credentials
|
||||
self.user_data = user_data
|
||||
self.username = credentials["imap_username"]
|
||||
self.password = credentials["imap_password"]
|
||||
self.on_new_email = on_new_email
|
||||
self._connected = connected
|
||||
|
||||
def buildProtocol(self, addr) -> IMAPClient:
|
||||
"""Build the IMAP client protocol.
|
||||
|
||||
@return: The IMAP client protocol.
|
||||
"""
|
||||
assert self.protocol is not None
|
||||
assert isinstance(self.protocol, type(IMAPClient))
|
||||
protocol_ = self.protocol(self._connected)
|
||||
protocol_.factory = self
|
||||
self.user_data.imap_client = protocol_
|
||||
assert isinstance(protocol_, IMAPClient)
|
||||
protocol_.factory = self
|
||||
encoded_username = self.username.encode()
|
||||
|
||||
protocol_.registerAuthenticator(imap4.PLAINAuthenticator(encoded_username))
|
||||
protocol_.registerAuthenticator(imap4.LOGINAuthenticator(encoded_username))
|
||||
protocol_.registerAuthenticator(
|
||||
imap4.CramMD5ClientAuthenticator(encoded_username)
|
||||
)
|
||||
|
||||
return protocol_
|
||||
|
||||
def clientConnectionFailed(self, connector, reason: Failure) -> None:
|
||||
"""Called when the client connection fails.
|
||||
|
||||
@param reason: The reason for the failure.
|
||||
"""
|
||||
log.warning(f"Connection failed: {reason}")
|
||||
30
libervia/backend/plugins/plugin_comp_email_gateway/models.py
Normal file
30
libervia/backend/plugins/plugin_comp_email_gateway/models.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Libervia Email Gateway Component
|
||||
# Copyright (C) 2009-2024 Jérôme Poisson (goffi@goffi.org)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from twisted.mail import imap4
|
||||
|
||||
|
||||
Credentials = dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserData:
|
||||
credentials: Credentials
|
||||
imap_client: imap4.IMAP4Client | None = None
|
||||
Loading…
Reference in a new issue