shade-plugins-email 0.1.0__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.
- shade_plugins_email/__init__.py +407 -0
- shade_plugins_email/_imap.py +196 -0
- shade_plugins_email/_mensagem.py +256 -0
- shade_plugins_email/_rede.py +190 -0
- shade_plugins_email/erros.py +39 -0
- shade_plugins_email-0.1.0.dist-info/METADATA +73 -0
- shade_plugins_email-0.1.0.dist-info/RECORD +10 -0
- shade_plugins_email-0.1.0.dist-info/WHEEL +4 -0
- shade_plugins_email-0.1.0.dist-info/entry_points.txt +2 -0
- shade_plugins_email-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""O plugin ``email``: ler por IMAP e enviar por SMTP sob o contrato de plugin do shade-sdk.
|
|
2
|
+
|
|
3
|
+
```toml
|
|
4
|
+
[config]
|
|
5
|
+
email_imap = "imap.empresa.com.br"
|
|
6
|
+
email_smtp = "smtp.empresa.com.br"
|
|
7
|
+
|
|
8
|
+
[hosts]
|
|
9
|
+
permitidos = ["imap.empresa.com.br", "smtp.empresa.com.br"]
|
|
10
|
+
|
|
11
|
+
[credenciais]
|
|
12
|
+
nomes = ["EMAIL_USUARIO", "EMAIL_SENHA"]
|
|
13
|
+
|
|
14
|
+
[plugins]
|
|
15
|
+
email = "0.1.0"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import shade_plugins_email as email
|
|
20
|
+
|
|
21
|
+
for msg in email.buscar(nao_lidas=True, assunto="pedido"):
|
|
22
|
+
email.baixar_anexos(item, msg["uid"])
|
|
23
|
+
|
|
24
|
+
with item.efetivar("avisar o cliente"):
|
|
25
|
+
email.enviar(para="cliente@exemplo.com", assunto="Pedido recebido", corpo="...")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- **Servidor do projeto:** o IMAP e o SMTP vêm de ``[config] email_imap`` e ``email_smtp`` e
|
|
29
|
+
têm de estar em ``[hosts] permitidos``; nem argumento nem item escolhem outro servidor. O
|
|
30
|
+
usuário e a senha vêm só de ``EMAIL_USUARIO`` e ``EMAIL_SENHA``.
|
|
31
|
+
- **TLS obrigatório:** na conexão (``tls="implicito"``, IMAP 993 e SMTP 465, o padrão) ou por
|
|
32
|
+
STARTTLS (``tls="starttls"``, 143 e 587). O certificado é verificado; o servidor sem STARTTLS
|
|
33
|
+
é recusado antes do login (``TlsObrigatorio``).
|
|
34
|
+
- **No executor da plataforma:** a conexão sai pelo proxy do sandbox (``SHADE_TUNEL_DO_PLUGIN``),
|
|
35
|
+
só nas portas 993, 465 e 587 dos hosts de ``[config]``; outra porta é ``ForaDoEgresso``. Fora
|
|
36
|
+
do sandbox a conexão é direta (o ``HTTPS_PROXY`` não é usado).
|
|
37
|
+
- **Ler não muda nada:** a caixa abre só para leitura e a mensagem é lida sem marcar como lida.
|
|
38
|
+
- **Enviar só dentro de** ``item.efetivar()`` (no ensaio não roda). O ``Message-ID`` deriva da
|
|
39
|
+
chave do item, do nome da efetivação e da ordem do envio no bloco: o mesmo em toda execução.
|
|
40
|
+
A conexão e o login são tentados de novo na falha de rede; depois que a mensagem começou a ir,
|
|
41
|
+
nunca (``EnvioIncerto``).
|
|
42
|
+
- **Prazo:** ``timeout=`` (30 s) vale para a chamada inteira, retentativas incluídas.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import contextlib
|
|
48
|
+
import datetime as dt
|
|
49
|
+
import os
|
|
50
|
+
import re
|
|
51
|
+
import smtplib
|
|
52
|
+
import ssl
|
|
53
|
+
from base64 import b64encode
|
|
54
|
+
from collections.abc import Iterable
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
from typing import Any
|
|
57
|
+
|
|
58
|
+
from shade_sdk.plugins import Plugin
|
|
59
|
+
|
|
60
|
+
from shade_plugins_email import _imap, _mensagem
|
|
61
|
+
from shade_plugins_email._rede import (
|
|
62
|
+
TLS_NA_CONEXAO,
|
|
63
|
+
TLS_POR_STARTTLS,
|
|
64
|
+
Prazo,
|
|
65
|
+
Smtp,
|
|
66
|
+
SmtpSsl,
|
|
67
|
+
antes_do_envio,
|
|
68
|
+
contexto_tls,
|
|
69
|
+
porta_padrao,
|
|
70
|
+
)
|
|
71
|
+
from shade_plugins_email.erros import (
|
|
72
|
+
EnvioIncerto,
|
|
73
|
+
EnvioNaoSaiu,
|
|
74
|
+
ErroDoEmail,
|
|
75
|
+
ForaDoEgresso,
|
|
76
|
+
MensagemNaoEncontrada,
|
|
77
|
+
TlsObrigatorio,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
__version__ = "0.1.0"
|
|
81
|
+
|
|
82
|
+
#: As chaves de ``[config]`` com os servidores do cliente.
|
|
83
|
+
IMAP = "email_imap"
|
|
84
|
+
SMTP = "email_smtp"
|
|
85
|
+
#: As credenciais que o plugin lê (``[credenciais] nomes``).
|
|
86
|
+
USUARIO = "EMAIL_USUARIO"
|
|
87
|
+
SENHA = "EMAIL_SENHA"
|
|
88
|
+
#: Quantas mensagens ``buscar`` devolve, no máximo.
|
|
89
|
+
LIMITE_MAX = 500
|
|
90
|
+
#: O nome com que o robô se apresenta ao SMTP: não o nome da máquina.
|
|
91
|
+
NOME_NO_EHLO = "localhost"
|
|
92
|
+
_CONTROLE = re.compile(r"[\x00-\x1f\x7f]")
|
|
93
|
+
|
|
94
|
+
PLUGIN = Plugin(
|
|
95
|
+
"email",
|
|
96
|
+
__version__,
|
|
97
|
+
shade_sdk=">=0.22.1,<0.23",
|
|
98
|
+
dono="Equipe de integrações da ShadeOne",
|
|
99
|
+
hosts_do_projeto=[IMAP, SMTP],
|
|
100
|
+
credenciais=[USUARIO, SENHA],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
__all__ = [
|
|
104
|
+
"PLUGIN",
|
|
105
|
+
"EnvioIncerto",
|
|
106
|
+
"EnvioNaoSaiu",
|
|
107
|
+
"ErroDoEmail",
|
|
108
|
+
"ForaDoEgresso",
|
|
109
|
+
"MensagemNaoEncontrada",
|
|
110
|
+
"TlsObrigatorio",
|
|
111
|
+
"baixar_anexos",
|
|
112
|
+
"buscar",
|
|
113
|
+
"enviar",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _credenciais(chamada: Any) -> tuple[str, str]:
|
|
118
|
+
usuario, senha = chamada.segredo(USUARIO), chamada.segredo(SENHA)
|
|
119
|
+
if _CONTROLE.search(usuario) or _CONTROLE.search(senha):
|
|
120
|
+
raise ValueError(f"{USUARIO} e {SENHA} não podem ter quebra de linha nem controle")
|
|
121
|
+
return usuario, senha
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _conectar_imap(chamada: Any, porta: Any, tls: str) -> tuple[Any, Prazo]:
|
|
125
|
+
porta = porta_padrao(porta, tls, 993, 143)
|
|
126
|
+
host = chamada.host_do_projeto(IMAP)
|
|
127
|
+
usuario, senha = _credenciais(chamada)
|
|
128
|
+
prazo = Prazo(chamada.timeout)
|
|
129
|
+
try:
|
|
130
|
+
return _imap.conectar(host, porta, tls, usuario, senha, prazo), prazo
|
|
131
|
+
except TlsObrigatorio:
|
|
132
|
+
raise
|
|
133
|
+
except _imap.imaplib.IMAP4.abort as exc:
|
|
134
|
+
raise ErroDoEmail("IMAP: a conexão caiu antes do login") from exc
|
|
135
|
+
except _imap.imaplib.IMAP4.error as exc:
|
|
136
|
+
raise ErroDoEmail("IMAP: o servidor recusou o login") from exc
|
|
137
|
+
except (OSError, ssl.SSLError) as exc:
|
|
138
|
+
raise ErroDoEmail(f"IMAP: a conexão não abriu ({type(exc).__name__})") from exc
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _lendo(funcao: Any) -> Any:
|
|
142
|
+
"""A falha de rede ou de protocolo no meio da leitura vira ``ErroDoEmail``."""
|
|
143
|
+
try:
|
|
144
|
+
return funcao()
|
|
145
|
+
except ErroDoEmail:
|
|
146
|
+
raise
|
|
147
|
+
except _imap.imaplib.IMAP4.error as exc:
|
|
148
|
+
raise ErroDoEmail(f"IMAP: o servidor recusou o comando ({type(exc).__name__})") from exc
|
|
149
|
+
except (OSError, ssl.SSLError) as exc:
|
|
150
|
+
raise ErroDoEmail(f"IMAP: a conexão caiu ({type(exc).__name__})") from exc
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@PLUGIN.operacao
|
|
154
|
+
def buscar(
|
|
155
|
+
chamada: Any,
|
|
156
|
+
caixa: str = "INBOX",
|
|
157
|
+
*,
|
|
158
|
+
nao_lidas: bool = False,
|
|
159
|
+
de: str | None = None,
|
|
160
|
+
assunto: str | None = None,
|
|
161
|
+
desde: dt.date | None = None,
|
|
162
|
+
limite: int = 50,
|
|
163
|
+
porta: int | None = None,
|
|
164
|
+
tls: str = TLS_NA_CONEXAO,
|
|
165
|
+
) -> list[dict[str, Any]]:
|
|
166
|
+
"""As mensagens da ``caixa``, da mais nova à mais antiga, até ``limite``: só as não lidas,
|
|
167
|
+
as de um remetente ou com um trecho no assunto (sem diferenciar maiúsculas), a partir de uma
|
|
168
|
+
data. Cada uma: ``uid``, ``message_id``, ``de``, ``para``, ``cc``, ``assunto``, ``data``,
|
|
169
|
+
``tamanho``, ``texto``, ``html``, ``anexos`` (nome, tipo, tamanho) e ``corpo_omitido`` (a
|
|
170
|
+
mensagem acima de 25 MiB vem só com o cabeçalho). Nada é marcado como lido."""
|
|
171
|
+
nome_da_caixa = _imap.caixa_quotada(caixa)
|
|
172
|
+
filtro = _imap.criterios(bool(nao_lidas), desde)
|
|
173
|
+
for valor, campo in ((de, "de"), (assunto, "assunto")):
|
|
174
|
+
if valor is not None:
|
|
175
|
+
_mensagem.sem_quebra(valor, campo)
|
|
176
|
+
if not isinstance(limite, int) or isinstance(limite, bool) or not 0 < limite <= LIMITE_MAX:
|
|
177
|
+
raise ValueError(f"limite é um número de 1 a {LIMITE_MAX}")
|
|
178
|
+
conexao, prazo = _conectar_imap(chamada, porta, tls)
|
|
179
|
+
try:
|
|
180
|
+
|
|
181
|
+
def ler() -> list[dict[str, Any]]:
|
|
182
|
+
_imap.abrir_caixa(conexao, nome_da_caixa, prazo)
|
|
183
|
+
uids = _imap.buscar_uids(conexao, filtro, prazo)
|
|
184
|
+
escolhidos: list[tuple[int, int, bytes]] = []
|
|
185
|
+
for lote in _imap.em_lotes(uids):
|
|
186
|
+
vistos = _imap.cabecalhos(conexao, lote, prazo)
|
|
187
|
+
for uid in lote:
|
|
188
|
+
if uid in vistos and _mensagem.casa(vistos[uid][1], de, assunto):
|
|
189
|
+
escolhidos.append((uid, *vistos[uid]))
|
|
190
|
+
if len(escolhidos) >= limite:
|
|
191
|
+
break
|
|
192
|
+
if len(escolhidos) >= limite:
|
|
193
|
+
break
|
|
194
|
+
resultado = []
|
|
195
|
+
for uid, tamanho, cabecalho in escolhidos:
|
|
196
|
+
dados = None
|
|
197
|
+
if tamanho <= _imap.MENSAGEM_MAX_BYTES:
|
|
198
|
+
dados = _imap.mensagem(conexao, uid, prazo)
|
|
199
|
+
resultado.append(_mensagem.descrever(str(uid), dados, cabecalho, tamanho))
|
|
200
|
+
return resultado
|
|
201
|
+
|
|
202
|
+
return _lendo(ler)
|
|
203
|
+
finally:
|
|
204
|
+
_imap.fechar(conexao)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _pasta_do_workdir(pasta: Any) -> Path:
|
|
208
|
+
if not isinstance(pasta, str) or not pasta.strip():
|
|
209
|
+
raise ValueError("pasta é um caminho relativo ao diretório de trabalho, como 'anexos'")
|
|
210
|
+
relativa = Path(pasta)
|
|
211
|
+
if relativa.is_absolute() or ".." in relativa.parts:
|
|
212
|
+
raise ValueError("pasta é um caminho relativo ao diretório de trabalho, sem '..'")
|
|
213
|
+
return Path.cwd().resolve() / relativa
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@PLUGIN.operacao
|
|
217
|
+
def baixar_anexos(
|
|
218
|
+
chamada: Any,
|
|
219
|
+
item: Any,
|
|
220
|
+
uid: str | int,
|
|
221
|
+
*,
|
|
222
|
+
caixa: str = "INBOX",
|
|
223
|
+
nomes: Iterable[str] | None = None,
|
|
224
|
+
pasta: str = "anexos",
|
|
225
|
+
porta: int | None = None,
|
|
226
|
+
tls: str = TLS_NA_CONEXAO,
|
|
227
|
+
) -> list[dict[str, Any]]:
|
|
228
|
+
"""Os anexos da mensagem ``uid`` (de ``buscar``) gravados em ``<pasta>/<uid>/`` no
|
|
229
|
+
diretório de trabalho e anexados ao item por ``item.anexar`` (a saída do run). ``nomes``
|
|
230
|
+
escolhe quais. Cada um: ``nome`` (o da mensagem), ``arquivo`` (o caminho gravado, com o nome
|
|
231
|
+
limpo de separador e controle), ``tipo``, ``tamanho`` e ``anexado`` (``False`` acima do teto
|
|
232
|
+
da saída do run; o arquivo fica no disco). A mensagem não é marcada como lida."""
|
|
233
|
+
anexar = getattr(item, "anexar", None)
|
|
234
|
+
if not callable(anexar):
|
|
235
|
+
raise TypeError("baixar_anexos(item, uid): passe o item do processar()")
|
|
236
|
+
texto_do_uid = str(uid)
|
|
237
|
+
if isinstance(uid, bool) or not texto_do_uid.isdigit() or not texto_do_uid.isascii():
|
|
238
|
+
raise ValueError("uid é o número que buscar() devolve")
|
|
239
|
+
escolhidos = None if nomes is None else ({nomes} if isinstance(nomes, str) else set(nomes))
|
|
240
|
+
nome_da_caixa = _imap.caixa_quotada(caixa)
|
|
241
|
+
destino = _pasta_do_workdir(pasta) / texto_do_uid
|
|
242
|
+
conexao, prazo = _conectar_imap(chamada, porta, tls)
|
|
243
|
+
try:
|
|
244
|
+
|
|
245
|
+
def ler() -> bytes | None:
|
|
246
|
+
_imap.abrir_caixa(conexao, nome_da_caixa, prazo)
|
|
247
|
+
return _imap.mensagem(conexao, int(texto_do_uid), prazo)
|
|
248
|
+
|
|
249
|
+
dados = _lendo(ler)
|
|
250
|
+
finally:
|
|
251
|
+
_imap.fechar(conexao)
|
|
252
|
+
if dados is None:
|
|
253
|
+
raise MensagemNaoEncontrada(f"IMAP: a mensagem {texto_do_uid} não está na caixa")
|
|
254
|
+
raiz = Path.cwd().resolve()
|
|
255
|
+
usados: set[str] = set()
|
|
256
|
+
resultado = []
|
|
257
|
+
for ordem, parte in enumerate(_mensagem.partes_anexadas(_mensagem.ler_bytes(dados)), start=1):
|
|
258
|
+
nome = _mensagem.nome_do_anexo(parte)
|
|
259
|
+
if escolhidos is not None and nome not in escolhidos:
|
|
260
|
+
continue
|
|
261
|
+
conteudo = parte.get_payload(decode=True) or b""
|
|
262
|
+
destino.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
caminho = destino / _mensagem.nome_seguro(nome, usados, ordem)
|
|
264
|
+
caminho.write_bytes(conteudo)
|
|
265
|
+
resultado.append(
|
|
266
|
+
{
|
|
267
|
+
"nome": nome,
|
|
268
|
+
"arquivo": str(caminho.relative_to(raiz)),
|
|
269
|
+
"tipo": parte.get_content_type(),
|
|
270
|
+
"tamanho": len(conteudo),
|
|
271
|
+
"anexado": bool(anexar(caminho)),
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
return resultado
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _abrir_smtp(host: str, porta: int, tls: str, usuario: str, senha: str, prazo: Prazo) -> Any:
|
|
278
|
+
contexto = contexto_tls()
|
|
279
|
+
if tls == TLS_NA_CONEXAO:
|
|
280
|
+
conexao: smtplib.SMTP = SmtpSsl(
|
|
281
|
+
host, porta, local_hostname=NOME_NO_EHLO, context=contexto, timeout=prazo.restante()
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
conexao = Smtp(host, porta, local_hostname=NOME_NO_EHLO, timeout=prazo.restante())
|
|
285
|
+
try:
|
|
286
|
+
prazo.no_socket(conexao.sock)
|
|
287
|
+
conexao.ehlo()
|
|
288
|
+
if tls == TLS_POR_STARTTLS:
|
|
289
|
+
if not conexao.has_extn("starttls"):
|
|
290
|
+
raise TlsObrigatorio(
|
|
291
|
+
"SMTP: o servidor não oferece STARTTLS; a senha não sai em texto aberto"
|
|
292
|
+
)
|
|
293
|
+
conexao.starttls(context=contexto)
|
|
294
|
+
prazo.no_socket(conexao.sock)
|
|
295
|
+
conexao.ehlo()
|
|
296
|
+
if usuario.isascii() and senha.isascii():
|
|
297
|
+
conexao.login(usuario, senha)
|
|
298
|
+
else:
|
|
299
|
+
# O ``login`` do smtplib codifica em ASCII; o AUTH PLAIN aceita UTF-8.
|
|
300
|
+
plano = b64encode(f"\0{usuario}\0{senha}".encode()).decode("ascii")
|
|
301
|
+
codigo, resposta = conexao.docmd("AUTH", "PLAIN " + plano)
|
|
302
|
+
if codigo != 235:
|
|
303
|
+
raise smtplib.SMTPAuthenticationError(codigo, resposta)
|
|
304
|
+
return conexao
|
|
305
|
+
except BaseException:
|
|
306
|
+
_fechar_smtp(conexao)
|
|
307
|
+
raise
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _fechar_smtp(conexao: Any) -> None:
|
|
311
|
+
if conexao is None:
|
|
312
|
+
return
|
|
313
|
+
try:
|
|
314
|
+
conexao.quit()
|
|
315
|
+
except Exception:
|
|
316
|
+
with contextlib.suppress(Exception):
|
|
317
|
+
conexao.close()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@PLUGIN.efeito
|
|
321
|
+
def enviar(
|
|
322
|
+
chamada: Any,
|
|
323
|
+
para: str | Iterable[str],
|
|
324
|
+
assunto: str,
|
|
325
|
+
corpo: str,
|
|
326
|
+
*,
|
|
327
|
+
cc: str | Iterable[str] | None = None,
|
|
328
|
+
cco: str | Iterable[str] | None = None,
|
|
329
|
+
de: str | None = None,
|
|
330
|
+
html: str | None = None,
|
|
331
|
+
anexos: Iterable[str | os.PathLike[str]] = (),
|
|
332
|
+
em_resposta_a: str | None = None,
|
|
333
|
+
porta: int | None = None,
|
|
334
|
+
tls: str = TLS_NA_CONEXAO,
|
|
335
|
+
) -> dict[str, Any]:
|
|
336
|
+
"""Envia uma mensagem (só dentro de ``item.efetivar()``). ``de`` é o remetente (o
|
|
337
|
+
``EMAIL_USUARIO``, se omitido); ``cco`` vai no envelope, nunca no cabeçalho; ``anexos`` são
|
|
338
|
+
arquivos do diretório de trabalho (até 20 MiB somados); ``em_resposta_a`` é o ``message_id``
|
|
339
|
+
da mensagem respondida. Devolve ``message_id`` e ``recusados`` (os destinatários que o
|
|
340
|
+
servidor recusou, quando aceitou os outros).
|
|
341
|
+
|
|
342
|
+
``EnvioNaoSaiu`` (um ``http.NadaSaiu``): o servidor não aceitou a mensagem.
|
|
343
|
+
``EnvioIncerto``: a mensagem começou a ir e a confirmação não chegou; não é repetida."""
|
|
344
|
+
porta = porta_padrao(porta, tls, 465, 587)
|
|
345
|
+
destino_para = _mensagem.enderecos(para, "para")
|
|
346
|
+
destino_cc = _mensagem.enderecos(cc, "cc")
|
|
347
|
+
destino_cco = _mensagem.enderecos(cco, "cco")
|
|
348
|
+
if not destino_para:
|
|
349
|
+
raise ValueError("para: ao menos um destinatário")
|
|
350
|
+
todos = [*destino_para, *destino_cc, *destino_cco]
|
|
351
|
+
if len(todos) > _mensagem.DESTINATARIOS_MAX:
|
|
352
|
+
raise ValueError(f"no máximo {_mensagem.DESTINATARIOS_MAX} destinatários por mensagem")
|
|
353
|
+
_mensagem.sem_quebra(assunto, "assunto")
|
|
354
|
+
if not isinstance(corpo, str) or (html is not None and not isinstance(html, str)):
|
|
355
|
+
raise TypeError("corpo e html são texto")
|
|
356
|
+
lista_de_anexos = [anexos] if isinstance(anexos, str | os.PathLike) else list(anexos)
|
|
357
|
+
arquivos = [_mensagem.arquivo_do_workdir(a) for a in lista_de_anexos]
|
|
358
|
+
referencia = None
|
|
359
|
+
if em_resposta_a is not None:
|
|
360
|
+
referencia = _mensagem.referencia(em_resposta_a, "em_resposta_a")
|
|
361
|
+
host = chamada.host_do_projeto(SMTP)
|
|
362
|
+
usuario, senha = _credenciais(chamada)
|
|
363
|
+
remetente = _mensagem.endereco(de if de is not None else usuario, "de")
|
|
364
|
+
mensagem = _mensagem.montar(
|
|
365
|
+
remetente=remetente,
|
|
366
|
+
para=destino_para,
|
|
367
|
+
cc=destino_cc,
|
|
368
|
+
assunto=assunto,
|
|
369
|
+
corpo=corpo,
|
|
370
|
+
html=html,
|
|
371
|
+
anexos=arquivos,
|
|
372
|
+
em_resposta_a=referencia,
|
|
373
|
+
chave=chamada.idempotencia,
|
|
374
|
+
)
|
|
375
|
+
prazo = Prazo(chamada.timeout)
|
|
376
|
+
try:
|
|
377
|
+
conexao = antes_do_envio(
|
|
378
|
+
lambda: _abrir_smtp(host, porta, tls, usuario, senha, prazo), prazo
|
|
379
|
+
)
|
|
380
|
+
except smtplib.SMTPAuthenticationError as exc:
|
|
381
|
+
raise EnvioNaoSaiu("SMTP: o servidor recusou o login") from exc
|
|
382
|
+
except (TlsObrigatorio, ForaDoEgresso) as exc:
|
|
383
|
+
raise EnvioNaoSaiu(str(exc)) from exc
|
|
384
|
+
except (OSError, ssl.SSLError, smtplib.SMTPException) as exc:
|
|
385
|
+
raise EnvioNaoSaiu(f"SMTP: a conexão não abriu ({type(exc).__name__})") from exc
|
|
386
|
+
try:
|
|
387
|
+
prazo.no_socket(conexao.sock)
|
|
388
|
+
except TimeoutError as exc:
|
|
389
|
+
_fechar_smtp(conexao)
|
|
390
|
+
raise EnvioNaoSaiu("SMTP: o prazo acabou antes do envio") from exc
|
|
391
|
+
# Daqui em diante, nenhuma retentativa: a mensagem pode chegar ao servidor.
|
|
392
|
+
try:
|
|
393
|
+
recusados = conexao.send_message(
|
|
394
|
+
mensagem, from_addr=remetente[1], to_addrs=[addr for _, addr in todos]
|
|
395
|
+
)
|
|
396
|
+
except (smtplib.SMTPRecipientsRefused, smtplib.SMTPSenderRefused) as exc:
|
|
397
|
+
raise EnvioNaoSaiu("SMTP: o servidor recusou o remetente ou os destinatários") from exc
|
|
398
|
+
except smtplib.SMTPDataError as exc:
|
|
399
|
+
raise EnvioNaoSaiu(f"SMTP: o servidor recusou a mensagem ({exc.smtp_code})") from exc
|
|
400
|
+
except (OSError, ssl.SSLError, smtplib.SMTPException) as exc:
|
|
401
|
+
raise EnvioIncerto(
|
|
402
|
+
f"SMTP: a conexão caiu durante o envio ({type(exc).__name__}); a mensagem pode ter "
|
|
403
|
+
"saído e não é repetida"
|
|
404
|
+
) from exc
|
|
405
|
+
finally:
|
|
406
|
+
_fechar_smtp(conexao)
|
|
407
|
+
return {"message_id": str(mensagem["Message-ID"]), "recusados": sorted(recusados)}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""O lado IMAP: conectar com TLS, abrir a caixa só para leitura e baixar sem marcar como lida.
|
|
2
|
+
|
|
3
|
+
Nenhum texto do robô vira comando: a caixa entra codificada (UTF-7 modificado) e entre aspas, a
|
|
4
|
+
busca no servidor usa só critérios montados aqui (``UNSEEN``, ``SINCE`` com a data formatada), e
|
|
5
|
+
o filtro de remetente e assunto roda no cliente. A caixa é aberta por ``EXAMINE`` e a mensagem é
|
|
6
|
+
lida por ``BODY.PEEK``: buscar não muda a marca de lida de nada.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import contextlib
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import imaplib
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Iterator, Sequence
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from shade_plugins_email._rede import (
|
|
20
|
+
TLS_NA_CONEXAO,
|
|
21
|
+
Imap,
|
|
22
|
+
ImapSsl,
|
|
23
|
+
Prazo,
|
|
24
|
+
antes_do_envio,
|
|
25
|
+
contexto_tls,
|
|
26
|
+
)
|
|
27
|
+
from shade_plugins_email.erros import ErroDoEmail, TlsObrigatorio
|
|
28
|
+
|
|
29
|
+
#: Quantas mensagens cada ``FETCH`` de cabeçalhos pede.
|
|
30
|
+
LOTE = 50
|
|
31
|
+
#: A mensagem acima disto vem só com o cabeçalho (``corpo_omitido``).
|
|
32
|
+
MENSAGEM_MAX_BYTES = 25 * 1024 * 1024
|
|
33
|
+
_MESES = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
34
|
+
_INICIO_DE_RESPOSTA = re.compile(rb"^\d+ \(")
|
|
35
|
+
_UID = re.compile(rb"\bUID (\d+)")
|
|
36
|
+
_TAMANHO = re.compile(rb"\bRFC822\.SIZE (\d+)")
|
|
37
|
+
_CONTROLE = re.compile(r"[\x00-\x1f\x7f]")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def caixa_quotada(nome: Any) -> str:
|
|
41
|
+
"""O nome da caixa como argumento IMAP: UTF-7 modificado (RFC 3501, 5.1.3) entre aspas."""
|
|
42
|
+
if not isinstance(nome, str) or not nome or len(nome) > 200 or _CONTROLE.search(nome):
|
|
43
|
+
raise ValueError("caixa é o nome da pasta (INBOX, Notas), sem caractere de controle")
|
|
44
|
+
partes: list[str] = []
|
|
45
|
+
fora: list[str] = []
|
|
46
|
+
|
|
47
|
+
def fechar() -> None:
|
|
48
|
+
if fora:
|
|
49
|
+
bruto = base64.b64encode("".join(fora).encode("utf-16-be")).rstrip(b"=")
|
|
50
|
+
partes.append("&" + bruto.replace(b"/", b",").decode("ascii") + "-")
|
|
51
|
+
fora.clear()
|
|
52
|
+
|
|
53
|
+
for caractere in nome:
|
|
54
|
+
if 0x20 <= ord(caractere) <= 0x7E:
|
|
55
|
+
fechar()
|
|
56
|
+
partes.append("&-" if caractere == "&" else caractere)
|
|
57
|
+
else:
|
|
58
|
+
fora.append(caractere)
|
|
59
|
+
fechar()
|
|
60
|
+
texto = "".join(partes)
|
|
61
|
+
return '"' + texto.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def criterios(nao_lidas: bool, desde: Any) -> list[str]:
|
|
65
|
+
resultado: list[str] = []
|
|
66
|
+
if nao_lidas:
|
|
67
|
+
resultado.append("UNSEEN")
|
|
68
|
+
if desde is not None:
|
|
69
|
+
if not isinstance(desde, dt.date):
|
|
70
|
+
raise TypeError("desde é uma data (datetime.date)")
|
|
71
|
+
resultado += ["SINCE", f"{desde.day:02d}-{_MESES[desde.month - 1]}-{desde.year:04d}"]
|
|
72
|
+
return resultado or ["ALL"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _quotar(texto: str) -> str:
|
|
76
|
+
return '"' + texto.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _entrar(conexao: imaplib.IMAP4, usuario: str, senha: str) -> None:
|
|
80
|
+
if usuario.isascii() and senha.isascii() and "LOGINDISABLED" not in conexao.capabilities:
|
|
81
|
+
# O ``login`` do imaplib põe a senha entre aspas, mas não o usuário.
|
|
82
|
+
conexao.login(_quotar(usuario), senha)
|
|
83
|
+
return
|
|
84
|
+
plano = f"\0{usuario}\0{senha}".encode()
|
|
85
|
+
conexao.authenticate("PLAIN", lambda _desafio: plano)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def fechar(conexao: imaplib.IMAP4 | None) -> None:
|
|
89
|
+
if conexao is None:
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
conexao.logout()
|
|
93
|
+
except Exception:
|
|
94
|
+
with contextlib.suppress(Exception):
|
|
95
|
+
conexao.shutdown()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def conectar(
|
|
99
|
+
host: str, porta: int, tls: str, usuario: str, senha: str, prazo: Prazo
|
|
100
|
+
) -> imaplib.IMAP4:
|
|
101
|
+
contexto = contexto_tls()
|
|
102
|
+
|
|
103
|
+
def abrir() -> imaplib.IMAP4:
|
|
104
|
+
if tls == TLS_NA_CONEXAO:
|
|
105
|
+
conexao: imaplib.IMAP4 = ImapSsl(
|
|
106
|
+
host, porta, ssl_context=contexto, timeout=prazo.restante()
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
conexao = Imap(host, porta, timeout=prazo.restante())
|
|
110
|
+
try:
|
|
111
|
+
if tls != TLS_NA_CONEXAO:
|
|
112
|
+
if "STARTTLS" not in conexao.capabilities:
|
|
113
|
+
raise TlsObrigatorio(
|
|
114
|
+
"IMAP: o servidor não oferece STARTTLS; a senha não sai em texto aberto"
|
|
115
|
+
)
|
|
116
|
+
prazo.no_socket(conexao.sock)
|
|
117
|
+
conexao.starttls(ssl_context=contexto)
|
|
118
|
+
prazo.no_socket(conexao.sock)
|
|
119
|
+
_entrar(conexao, usuario, senha)
|
|
120
|
+
except BaseException:
|
|
121
|
+
fechar(conexao)
|
|
122
|
+
raise
|
|
123
|
+
return conexao
|
|
124
|
+
|
|
125
|
+
return antes_do_envio(abrir, prazo)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def abrir_caixa(conexao: imaplib.IMAP4, caixa: str, prazo: Prazo) -> None:
|
|
129
|
+
prazo.no_socket(conexao.sock)
|
|
130
|
+
status, _ = conexao.select(caixa, readonly=True)
|
|
131
|
+
if status != "OK":
|
|
132
|
+
raise ErroDoEmail("IMAP: a caixa não existe ou não pode ser lida")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def buscar_uids(conexao: imaplib.IMAP4, filtro: Sequence[str], prazo: Prazo) -> list[int]:
|
|
136
|
+
"""Os UIDs que casam, do mais novo ao mais antigo."""
|
|
137
|
+
prazo.no_socket(conexao.sock)
|
|
138
|
+
status, dados = conexao.uid("SEARCH", *filtro)
|
|
139
|
+
if status != "OK":
|
|
140
|
+
raise ErroDoEmail("IMAP: o servidor recusou a busca")
|
|
141
|
+
numeros = b" ".join(d for d in dados if isinstance(d, bytes)).split()
|
|
142
|
+
return sorted({int(n) for n in numeros if n.isdigit()}, reverse=True)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _respostas(dados: Sequence[Any]) -> Iterator[tuple[bytes, bytes | None]]:
|
|
146
|
+
"""Cada resposta do ``FETCH``: (o texto dela, sem o literal; o literal)."""
|
|
147
|
+
atual: list[Any] | None = None
|
|
148
|
+
for parte in dados:
|
|
149
|
+
if isinstance(parte, tuple):
|
|
150
|
+
if atual is not None:
|
|
151
|
+
yield atual[0], atual[1]
|
|
152
|
+
atual = [parte[0], parte[1]]
|
|
153
|
+
elif isinstance(parte, bytes):
|
|
154
|
+
if _INICIO_DE_RESPOSTA.match(parte):
|
|
155
|
+
if atual is not None:
|
|
156
|
+
yield atual[0], atual[1]
|
|
157
|
+
atual = [parte, None]
|
|
158
|
+
elif atual is not None:
|
|
159
|
+
atual[0] += parte
|
|
160
|
+
if atual is not None:
|
|
161
|
+
yield atual[0], atual[1]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _buscar(conexao: imaplib.IMAP4, uids: str, itens: str, prazo: Prazo) -> Sequence[Any]:
|
|
165
|
+
prazo.no_socket(conexao.sock)
|
|
166
|
+
status, dados = conexao.uid("FETCH", uids, itens)
|
|
167
|
+
if status != "OK":
|
|
168
|
+
raise ErroDoEmail("IMAP: o servidor recusou a leitura")
|
|
169
|
+
return dados
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cabecalhos(
|
|
173
|
+
conexao: imaplib.IMAP4, uids: Sequence[int], prazo: Prazo
|
|
174
|
+
) -> dict[int, tuple[int, bytes]]:
|
|
175
|
+
"""UID → (tamanho, cabeçalho), sem marcar como lida."""
|
|
176
|
+
resultado: dict[int, tuple[int, bytes]] = {}
|
|
177
|
+
dados = _buscar(conexao, ",".join(map(str, uids)), "(UID RFC822.SIZE BODY.PEEK[HEADER])", prazo)
|
|
178
|
+
for texto, literal in _respostas(dados):
|
|
179
|
+
uid, tamanho = _UID.search(texto), _TAMANHO.search(texto)
|
|
180
|
+
if uid and literal is not None:
|
|
181
|
+
resultado[int(uid[1])] = (int(tamanho[1]) if tamanho else len(literal), literal)
|
|
182
|
+
return resultado
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def mensagem(conexao: imaplib.IMAP4, uid: int, prazo: Prazo) -> bytes | None:
|
|
186
|
+
"""A mensagem inteira, sem marcar como lida; ``None`` se o UID não existe."""
|
|
187
|
+
for texto, literal in _respostas(_buscar(conexao, str(uid), "(UID BODY.PEEK[])", prazo)):
|
|
188
|
+
achado = _UID.search(texto)
|
|
189
|
+
if achado and int(achado[1]) == uid and literal is not None:
|
|
190
|
+
return literal
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def em_lotes(uids: Sequence[int]) -> Iterator[Sequence[int]]:
|
|
195
|
+
for inicio in range(0, len(uids), LOTE):
|
|
196
|
+
yield uids[inicio : inicio + LOTE]
|