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.
@@ -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]