shade-plugins-ms365 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,934 @@
1
+ """O plugin ``ms365``: ler e-mails e baixar anexos do Outlook, enviar e-mail, e listar, baixar e
2
+ enviar arquivos do SharePoint, pela Microsoft Graph, sob o contrato de plugin do shade-sdk, com o
3
+ token OAuth da conexão da plataforma.
4
+
5
+ ```toml
6
+ [hosts]
7
+ permitidos = ["graph.microsoft.com", "contoso.sharepoint.com"]
8
+
9
+ [conexoes]
10
+ nomes = ["microsoft-365"]
11
+
12
+ [config]
13
+ sharepoint_host = "contoso.sharepoint.com" # só para baixar arquivo do SharePoint
14
+
15
+ [plugins]
16
+ ms365 = "0.1.0"
17
+ ```
18
+
19
+ ```python
20
+ import shade_plugins_ms365 as ms365
21
+
22
+ for email in ms365.ler_emails("inbox", nao_lidos=True):
23
+ ms365.baixar_anexos(item, email["id"])
24
+
25
+ with item.efetivar("avisar o cliente"):
26
+ ms365.enviar_email("cliente@exemplo.com", f"Pedido {item['pedido']}", "Recebemos o pedido.")
27
+
28
+ site = "contoso.sharepoint.com:/sites/Financeiro"
29
+ for arquivo in ms365.listar_arquivos(site, "Notas/2026"):
30
+ ms365.baixar_arquivo(site, "Notas/2026/" + arquivo["nome"])
31
+
32
+ with item.efetivar("guardar o comprovante"):
33
+ ms365.enviar_arquivo("comprovante.pdf", site, "Comprovantes")
34
+ ```
35
+
36
+ - **Conta:** a conexão ``microsoft-365`` da plataforma (Conectores). O robô nunca vê a senha nem
37
+ o refresh token: o token de acesso chega sob demanda e é renovado pela plataforma. No laptop,
38
+ ponha um token de acesso em ``SHADE_TOKEN_MICROSOFT_365``. A caixa de e-mail é a da conta
39
+ conectada (``/me``); caixa compartilhada fica fora desta versão.
40
+ - **Ler não muda nada:** ``ler_emails``, ``ler_email``, ``baixar_anexos``, ``listar_arquivos`` e
41
+ ``baixar_arquivo`` rodam inclusive no ensaio, e nenhuma mensagem vira lida.
42
+ - **Escrever só dentro de** ``item.efetivar()`` (no ensaio não roda):
43
+ - ``enviar_email`` não duplica: a mensagem leva um ``Message-ID`` derivado da chave do item,
44
+ do nome da efetivação e da ordem do envio, e antes de criar o rascunho o plugin procura esse
45
+ identificador em Enviados e em Rascunhos. Depois que o envio saiu sem confirmação, o plugin
46
+ não envia de novo: ele confere Enviados e, sem achar, o erro é ``GraphIncerto`` (o item fica
47
+ retido para conferência).
48
+ - ``enviar_arquivo`` grava no caminho dado: repetir sobrescreve o mesmo arquivo, então a
49
+ retentativa e o "refazer" deixam um arquivo só. ``existente="falhar"`` recusa o caminho
50
+ ocupado, e ``chave_no_nome=True`` põe a chave da chamada no nome, para um item não sobrescrever
51
+ o arquivo de outro.
52
+ - **Prazo:** ``timeout=`` (30 s) vale para a chamada inteira, retentativas incluídas.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import hashlib
58
+ import json
59
+ import mimetypes
60
+ import os
61
+ import re
62
+ import urllib.parse
63
+ from collections.abc import Callable
64
+ from datetime import UTC, datetime
65
+ from pathlib import Path
66
+ from typing import Any
67
+
68
+ from shade_sdk.plugins import Plugin
69
+
70
+ from shade_plugins_ms365 import _cliente
71
+ from shade_plugins_ms365._cliente import CONEXAO, GRAPH, RAIZ, Cliente, Prazo
72
+ from shade_plugins_ms365.erros import (
73
+ ArquivoJaExiste,
74
+ ErroDaGraph,
75
+ GraphIncerto,
76
+ GraphNaoSaiu,
77
+ NaoEncontrado,
78
+ )
79
+
80
+ __version__ = "0.1.0"
81
+
82
+ #: A chave de ``[config]`` com o host do tenant no SharePoint, para onde a Graph redireciona o
83
+ #: download (``contoso.sharepoint.com``).
84
+ HOST_DO_SHAREPOINT = "sharepoint_host"
85
+ #: Quantos itens as listagens devolvem, no máximo, e quantos a Graph dá por página.
86
+ LIMITE_EMAILS_MAX = 1_000
87
+ LIMITE_ARQUIVOS_MAX = 10_000
88
+ POR_PAGINA = 100
89
+ #: Quantas páginas ``ler_emails`` percorre atrás do que passa nos filtros do cliente.
90
+ PAGINAS_MAX = 50
91
+ #: O teto de um download (anexo ou arquivo) e de um envio (um ``PUT`` só).
92
+ ARQUIVO_MAX = 150 * 1024 * 1024
93
+ ENVIO_MAX = 100 * 1024 * 1024
94
+ #: O teto do texto de um e-mail e dos destinatários.
95
+ CORPO_MAX = 1_000_000
96
+ DESTINATARIOS_MAX = 100
97
+ #: O domínio do ``Message-ID``: o ``.invalid`` é reservado (RFC 2606) e nunca é de ninguém.
98
+ DOMINIO_DO_ID = "ms365.shadeone.invalid"
99
+ #: Quantos caracteres da ``chamada.idempotencia`` vão no ``Message-ID`` e no nome com chave.
100
+ TAMANHO_DA_CHAVE = 32
101
+ TAMANHO_DA_CHAVE_NO_NOME = 12
102
+ _CONTROLE = re.compile(r"[\x00-\x1f\x7f]")
103
+ #: O id de mensagem, pasta, anexo e item da Graph, e o nome conhecido da pasta (``inbox``).
104
+ _ID = re.compile(r"^[A-Za-z0-9_=+\-]{1,512}$")
105
+ _ENDERECO = re.compile(
106
+ r"^[^\s<>()\[\]\\,;:@\"]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.\-]{0,251}[A-Za-z0-9])?$"
107
+ )
108
+ _HOST = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9.\-]{0,251}[A-Za-z0-9])?$")
109
+ _GUID = r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}"
110
+ _SITE_POR_ID = re.compile(rf"^({_HOST.pattern[1:-1]}),({_GUID}),({_GUID})$")
111
+ _SITE_POR_CAMINHO = re.compile(rf"^({_HOST.pattern[1:-1]}):((?:/[^/]+)+)$")
112
+ #: O que o SharePoint não aceita num nome (e o ``/`` que separa os segmentos).
113
+ _PROIBIDOS_NO_NOME = set('"*:<>?/\\|')
114
+ _ANEXO_DE_ARQUIVO = "#microsoft.graph.fileAttachment"
115
+ #: O conteúdo de um anexo ou de um arquivo é binário: o ``Accept`` padrão (JSON) não serve.
116
+ _QUALQUER_TIPO = {"Accept": "*/*"}
117
+
118
+ PLUGIN = Plugin(
119
+ "ms365",
120
+ __version__,
121
+ shade_sdk=">=0.22.3,<0.23",
122
+ dono="Equipe de integrações da ShadeOne",
123
+ hosts=[GRAPH],
124
+ hosts_do_projeto=[HOST_DO_SHAREPOINT],
125
+ conexoes=[CONEXAO],
126
+ )
127
+
128
+ __all__ = [
129
+ "PLUGIN",
130
+ "ArquivoJaExiste",
131
+ "ErroDaGraph",
132
+ "GraphIncerto",
133
+ "GraphNaoSaiu",
134
+ "NaoEncontrado",
135
+ "baixar_anexos",
136
+ "baixar_arquivo",
137
+ "enviar_arquivo",
138
+ "enviar_email",
139
+ "ler_email",
140
+ "ler_emails",
141
+ "listar_arquivos",
142
+ ]
143
+
144
+
145
+ # ── as réguas ─────────────────────────────────────────────────────────────────────────────
146
+
147
+
148
+ def _id(valor: Any, campo: str) -> str:
149
+ if not isinstance(valor, str) or not _ID.fullmatch(valor):
150
+ raise ValueError(
151
+ f"{campo} é o id que a Graph devolveu (letras, dígitos, '-', '_', '=', '+')"
152
+ )
153
+ return urllib.parse.quote(valor, safe="")
154
+
155
+
156
+ def _porta(porta: Any) -> int | None:
157
+ if porta is not None and (
158
+ not isinstance(porta, int) or isinstance(porta, bool) or not 0 < porta < 65536
159
+ ):
160
+ raise ValueError(f"porta é um número de 1 a 65535, não {porta!r}")
161
+ return porta
162
+
163
+
164
+ def _limite(valor: Any, maximo: int) -> int:
165
+ if not isinstance(valor, int) or isinstance(valor, bool) or not 0 < valor <= maximo:
166
+ raise ValueError(f"limite é um número de 1 a {maximo}")
167
+ return valor
168
+
169
+
170
+ def literal_odata(valor: str) -> str:
171
+ """O texto como literal de um ``$filter`` do OData: entre aspas simples, com a aspa do valor
172
+ dobrada (a regra do OData). Quebra de linha e controle são recusados."""
173
+ if not isinstance(valor, str) or _CONTROLE.search(valor):
174
+ raise ValueError("o texto do filtro não pode ter quebra de linha nem caractere de controle")
175
+ return "'" + valor.replace("'", "''") + "'"
176
+
177
+
178
+ def _segmento(valor: Any, campo: str) -> str:
179
+ """Um nome de pasta ou arquivo do SharePoint, já codificado para o caminho."""
180
+ if (
181
+ not isinstance(valor, str)
182
+ or not valor
183
+ or len(valor) > 255
184
+ or valor in (".", "..")
185
+ or valor != valor.strip()
186
+ or valor.endswith(".")
187
+ or _CONTROLE.search(valor)
188
+ or any(c in _PROIBIDOS_NO_NOME for c in valor)
189
+ ):
190
+ raise ValueError(
191
+ f"{campo}: nome inválido no SharePoint (vazio, '.', '..', espaço nas pontas, ponto "
192
+ 'no fim, controle ou um destes: " * : < > ? / \\ |)'
193
+ )
194
+ return urllib.parse.quote(valor, safe="")
195
+
196
+
197
+ def _caminho(valor: Any, campo: str, *, vazio: bool) -> list[str]:
198
+ """Os segmentos já codificados de um caminho relativo (``Notas/2026``)."""
199
+ if not isinstance(valor, str):
200
+ raise ValueError(f"{campo} é um caminho relativo, como 'Notas/2026'")
201
+ texto = valor.strip("/")
202
+ if not texto:
203
+ if vazio:
204
+ return []
205
+ raise ValueError(f"{campo} é um caminho relativo, como 'Notas/2026/nota.pdf'")
206
+ return [_segmento(parte, campo) for parte in texto.split("/")]
207
+
208
+
209
+ def _site(valor: Any) -> str:
210
+ """O site como a Graph o endereça, em ``/sites/...``: ``root``, o id
211
+ (``contoso.sharepoint.com,<guid>,<guid>``) ou ``host:/sites/Nome``. No caminho do site, a
212
+ forma por caminho leva o ``:`` que fecha o segmento."""
213
+ if valor == "root":
214
+ return "/sites/root"
215
+ if isinstance(valor, str) and _SITE_POR_ID.fullmatch(valor):
216
+ return "/sites/" + urllib.parse.quote(valor, safe=",")
217
+ if isinstance(valor, str) and (achado := _SITE_POR_CAMINHO.fullmatch(valor)):
218
+ partes = [_segmento(p, "site") for p in achado[2].strip("/").split("/")]
219
+ return f"/sites/{achado[1]}:/{'/'.join(partes)}:"
220
+ raise ValueError(
221
+ "site é 'root', o id do site (host,guid,guid) ou 'host:/sites/Nome', "
222
+ "como 'contoso.sharepoint.com:/sites/Financeiro'"
223
+ )
224
+
225
+
226
+ def _drive(site: str) -> str:
227
+ """O drive padrão do site (a biblioteca de documentos)."""
228
+ return f"{RAIZ}{site}/drive"
229
+
230
+
231
+ def _no_drive(site: str, partes: list[str], *, depois: str = "") -> str:
232
+ """O caminho de um item do drive do site: a raiz (sem ``partes``) ou ``root:/a/b:``."""
233
+ if not partes:
234
+ return f"{_drive(site)}/root{depois}"
235
+ return f"{_drive(site)}/root:/{'/'.join(partes)}:{depois}"
236
+
237
+
238
+ def _nome_de_arquivo(nome: str, usados: set[str] | None = None) -> str:
239
+ limpo = _CONTROLE.sub("_", nome.replace("/", "_").replace("\\", "_")).strip(". ")
240
+ limpo = (limpo or "arquivo")[:200]
241
+ if usados is None:
242
+ return limpo
243
+ final, contador = limpo, 1
244
+ while final.casefold() in usados:
245
+ contador += 1
246
+ raiz, extensao = os.path.splitext(limpo)
247
+ final = f"{raiz} ({contador}){extensao}"
248
+ usados.add(final.casefold())
249
+ return final
250
+
251
+
252
+ def _pasta_relativa(pasta: Any) -> Path:
253
+ if not isinstance(pasta, str) or not pasta or _CONTROLE.search(pasta):
254
+ raise ValueError("pasta é um caminho relativo do diretório de trabalho")
255
+ partes = pasta.split("/")
256
+ if pasta.startswith("/") or any(p in ("", ".", "..") for p in partes):
257
+ raise ValueError("pasta é um caminho relativo, sem '..' nem segmento vazio")
258
+ return Path(pasta)
259
+
260
+
261
+ def _arquivo_do_workdir(caminho: Any) -> Path:
262
+ if not isinstance(caminho, str | os.PathLike):
263
+ raise TypeError("arquivo é o caminho de um arquivo do diretório de trabalho")
264
+ relativo = Path(caminho)
265
+ raiz = Path.cwd().resolve()
266
+ completo = (raiz / relativo).resolve()
267
+ if relativo.is_absolute() or completo != raiz and raiz not in completo.parents:
268
+ raise ValueError("arquivo é um caminho dentro do diretório de trabalho")
269
+ if not completo.is_file():
270
+ raise FileNotFoundError("arquivo: não existe no diretório de trabalho")
271
+ return completo
272
+
273
+
274
+ def _enderecos(valor: Any, campo: str, *, obrigatorio: bool) -> list[str]:
275
+ if valor is None:
276
+ valor = []
277
+ if isinstance(valor, str):
278
+ valor = [valor]
279
+ if not isinstance(valor, list | tuple):
280
+ raise TypeError(f"{campo} é um endereço ou uma lista de endereços")
281
+ if obrigatorio and not valor:
282
+ raise ValueError(f"{campo} precisa de ao menos um destinatário")
283
+ resultado = []
284
+ for endereco in valor:
285
+ if not isinstance(endereco, str) or not _ENDERECO.fullmatch(endereco.strip()):
286
+ raise ValueError(f"{campo}: endereço de e-mail inválido")
287
+ resultado.append(endereco.strip())
288
+ return resultado
289
+
290
+
291
+ def _destinatarios(enderecos: list[str]) -> list[dict[str, Any]]:
292
+ return [{"emailAddress": {"address": e}} for e in enderecos]
293
+
294
+
295
+ def _json(dados: Any) -> bytes:
296
+ return json.dumps(dados, ensure_ascii=False).encode("utf-8")
297
+
298
+
299
+ def _lendo(operacao: str, funcao: Callable[[], Any]) -> Any:
300
+ """A falha de rede no meio da leitura vira ``ErroDaGraph``."""
301
+ try:
302
+ return funcao()
303
+ except ErroDaGraph:
304
+ raise
305
+ except OSError as exc:
306
+ raise _cliente.falha_de_leitura(operacao, exc) from exc
307
+
308
+
309
+ def _cliente_da_chamada(chamada: Any, porta: Any) -> Cliente:
310
+ return Cliente(chamada=chamada, prazo=Prazo(chamada.timeout), porta=_porta(porta))
311
+
312
+
313
+ def _paginas(cliente: Cliente, operacao: str, caminho: str, parametros: dict[str, str]) -> Any:
314
+ """Os itens de ``value`` de cada página, seguindo ``@odata.nextLink`` só dentro do host da
315
+ Graph (o link é dado do servidor: o token não vai a outro host)."""
316
+ consulta_crua = ""
317
+ while True:
318
+ resposta = cliente.pedir(
319
+ "GET", GRAPH, caminho, parametros=parametros, consulta_crua=consulta_crua
320
+ )
321
+ _cliente.checar_leitura(operacao, resposta)
322
+ dados = resposta.json()
323
+ for item in dados.get("value") or []:
324
+ if isinstance(item, dict):
325
+ yield item
326
+ proximo = dados.get("@odata.nextLink")
327
+ if not isinstance(proximo, str) or not proximo:
328
+ return
329
+ partes = urllib.parse.urlsplit(proximo)
330
+ if (
331
+ partes.scheme != "https"
332
+ or (partes.hostname or "").lower() != GRAPH
333
+ or partes.username
334
+ or not partes.path.startswith(RAIZ + "/")
335
+ ):
336
+ raise ErroDaGraph(f"Graph {operacao}: a próxima página aponta para fora da Graph")
337
+ caminho, parametros, consulta_crua = partes.path, {}, partes.query
338
+
339
+
340
+ # ── Outlook: ler ──────────────────────────────────────────────────────────────────────────
341
+
342
+ _CAMPOS_DA_MENSAGEM = "id,internetMessageId,subject,from,receivedDateTime,isRead,hasAttachments"
343
+
344
+
345
+ def _endereco_do_remetente(mensagem: dict[str, Any]) -> tuple[str, str]:
346
+ de = (mensagem.get("from") or {}).get("emailAddress") or {}
347
+ return str(de.get("address") or ""), str(de.get("name") or "")
348
+
349
+
350
+ def _resumo(mensagem: dict[str, Any]) -> dict[str, Any]:
351
+ endereco, nome = _endereco_do_remetente(mensagem)
352
+ return {
353
+ "id": mensagem.get("id", ""),
354
+ "internet_id": mensagem.get("internetMessageId", ""),
355
+ "assunto": mensagem.get("subject") or "",
356
+ "de": endereco,
357
+ "de_nome": nome,
358
+ "recebido": mensagem.get("receivedDateTime", ""),
359
+ "lido": bool(mensagem.get("isRead")),
360
+ "tem_anexos": bool(mensagem.get("hasAttachments")),
361
+ }
362
+
363
+
364
+ def _instante(valor: Any) -> str:
365
+ """``desde`` como o instante UTC que o OData lê (``2026-10-25T00:00:00Z``)."""
366
+ if isinstance(valor, datetime):
367
+ instante = valor
368
+ elif isinstance(valor, str):
369
+ try:
370
+ instante = datetime.fromisoformat(valor.strip().replace("Z", "+00:00"))
371
+ except ValueError:
372
+ raise ValueError(
373
+ "desde é uma data ISO, como '2026-10-25' ou '2026-10-25T08:00:00Z'"
374
+ ) from None
375
+ else:
376
+ raise ValueError("desde é uma data ISO, como '2026-10-25' ou '2026-10-25T08:00:00Z'")
377
+ if instante.tzinfo is None:
378
+ instante = instante.replace(tzinfo=UTC)
379
+ return instante.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
380
+
381
+
382
+ @PLUGIN.operacao
383
+ def ler_emails(
384
+ chamada: Any,
385
+ pasta: str = "inbox",
386
+ *,
387
+ nao_lidos: bool = False,
388
+ desde: str | datetime | None = None,
389
+ remetente: str | None = None,
390
+ assunto: str | None = None,
391
+ limite: int = 50,
392
+ porta: int | None = None,
393
+ ) -> list[dict[str, Any]]:
394
+ """As mensagens de ``pasta`` (``inbox``, ``sentitems``, ``archive``... ou o id de uma pasta),
395
+ as mais recentes primeiro, até ``limite``. ``nao_lidos`` e ``desde`` (uma data ISO) filtram na
396
+ Graph; ``remetente`` e ``assunto`` (um pedaço do endereço e do assunto, sem diferenciar
397
+ maiúsculas) filtram aqui, depois da leitura. Cada uma: ``id``, ``internet_id``, ``assunto``,
398
+ ``de``, ``de_nome``, ``recebido``, ``lido`` e ``tem_anexos``. Nada vira lido."""
399
+ destino = _id(pasta, "pasta")
400
+ limite = _limite(limite, LIMITE_EMAILS_MAX)
401
+ parametros = {"$select": _CAMPOS_DA_MENSAGEM, "$top": str(POR_PAGINA)}
402
+ filtros = []
403
+ if nao_lidos:
404
+ filtros.append("isRead eq false")
405
+ if desde is not None:
406
+ filtros.append(f"receivedDateTime ge {_instante(desde)}")
407
+ if filtros:
408
+ parametros["$filter"] = " and ".join(filtros)
409
+ for campo, valor in (("remetente", remetente), ("assunto", assunto)):
410
+ if valor is not None and (not isinstance(valor, str) or not valor):
411
+ raise ValueError(f"{campo} é um texto não vazio")
412
+ cliente = _cliente_da_chamada(chamada, porta)
413
+ caminho = f"{RAIZ}/me/mailFolders/{destino}/messages"
414
+
415
+ def ler() -> list[dict[str, Any]]:
416
+ achadas: list[dict[str, Any]] = []
417
+ for percorridas, mensagem in enumerate(
418
+ _paginas(cliente, "ler_emails", caminho, parametros)
419
+ ):
420
+ if percorridas >= PAGINAS_MAX * POR_PAGINA:
421
+ break
422
+ resumo = _resumo(mensagem)
423
+ if remetente and remetente.casefold() not in resumo["de"].casefold():
424
+ continue
425
+ if assunto and assunto.casefold() not in resumo["assunto"].casefold():
426
+ continue
427
+ achadas.append(resumo)
428
+ if len(achadas) >= limite:
429
+ break
430
+ return achadas
431
+
432
+ return _lendo("ler_emails", ler)
433
+
434
+
435
+ @PLUGIN.operacao
436
+ def ler_email(chamada: Any, mensagem: str, *, porta: int | None = None) -> dict[str, Any]:
437
+ """Uma mensagem inteira, pelo ``id`` de ``ler_emails``: o resumo mais ``para`` e ``cc`` (listas
438
+ de endereços) e ``corpo`` (em texto). Não marca como lida."""
439
+ identificador = _id(mensagem, "mensagem")
440
+ cliente = _cliente_da_chamada(chamada, porta)
441
+
442
+ def ler() -> dict[str, Any]:
443
+ resposta = cliente.pedir(
444
+ "GET",
445
+ GRAPH,
446
+ f"{RAIZ}/me/messages/{identificador}",
447
+ parametros={"$select": f"{_CAMPOS_DA_MENSAGEM},body,toRecipients,ccRecipients"},
448
+ cabecalhos={"Prefer": 'outlook.body-content-type="text"'},
449
+ )
450
+ _cliente.checar_leitura("ler_email", resposta)
451
+ dados = resposta.json()
452
+
453
+ def enderecos(campo: str) -> list[str]:
454
+ return [
455
+ str((r.get("emailAddress") or {}).get("address") or "")
456
+ for r in dados.get(campo) or []
457
+ if isinstance(r, dict)
458
+ ]
459
+
460
+ return {
461
+ **_resumo(dados),
462
+ "para": enderecos("toRecipients"),
463
+ "cc": enderecos("ccRecipients"),
464
+ "corpo": str((dados.get("body") or {}).get("content") or ""),
465
+ }
466
+
467
+ return _lendo("ler_email", ler)
468
+
469
+
470
+ @PLUGIN.operacao
471
+ def baixar_anexos(
472
+ chamada: Any,
473
+ item: Any,
474
+ mensagem: str,
475
+ *,
476
+ nomes: list[str] | tuple[str, ...] | str | None = None,
477
+ incluir_inline: bool = False,
478
+ pasta: str = "anexos",
479
+ porta: int | None = None,
480
+ ) -> list[dict[str, Any]]:
481
+ """Os anexos de arquivo da mensagem ``mensagem`` (o ``id`` de ``ler_emails``) gravados em
482
+ ``<pasta>/<12 caracteres do id>/`` no diretório de trabalho e anexados ao item por
483
+ ``item.anexar`` (a saída do run). ``nomes`` escolhe quais; as imagens embutidas no corpo só
484
+ entram com ``incluir_inline``. Cada um: ``nome`` (o da mensagem), ``arquivo`` (o caminho
485
+ gravado, com o nome limpo de separador e controle), ``tipo``, ``tamanho`` e ``anexado``
486
+ (``False`` acima do teto da saída do run; o arquivo fica no disco). O anexo que é outra
487
+ mensagem ou um link vem com ``ignorado`` e sem arquivo. A mensagem não é marcada como lida."""
488
+ anexar = getattr(item, "anexar", None)
489
+ if not callable(anexar):
490
+ raise TypeError("baixar_anexos(item, mensagem): passe o item do processar()")
491
+ identificador = _id(mensagem, "mensagem")
492
+ escolhidos = None if nomes is None else ({nomes} if isinstance(nomes, str) else set(nomes))
493
+ destino = _pasta_relativa(pasta) / hashlib.sha256(mensagem.encode()).hexdigest()[:12]
494
+ cliente = _cliente_da_chamada(chamada, porta)
495
+ base = f"{RAIZ}/me/messages/{identificador}/attachments"
496
+ raiz = Path.cwd().resolve()
497
+
498
+ def ler() -> list[dict[str, Any]]:
499
+ resultado: list[dict[str, Any]] = []
500
+ usados: set[str] = set()
501
+ parametros = {"$select": "id,name,contentType,size,isInline", "$top": str(POR_PAGINA)}
502
+ for anexo in _paginas(cliente, "baixar_anexos", base, parametros):
503
+ nome = str(anexo.get("name") or "")
504
+ tipo = str(anexo.get("contentType") or "")
505
+ if escolhidos is not None and nome not in escolhidos:
506
+ continue
507
+ if anexo.get("@odata.type") != _ANEXO_DE_ARQUIVO:
508
+ resultado.append(
509
+ {
510
+ "nome": nome,
511
+ "arquivo": None,
512
+ "tipo": tipo,
513
+ "tamanho": None,
514
+ "anexado": False,
515
+ "ignorado": "anexo que não é arquivo (mensagem ou link)",
516
+ }
517
+ )
518
+ continue
519
+ if anexo.get("isInline") and not incluir_inline:
520
+ continue
521
+ identificador_do_anexo = anexo.get("id")
522
+ if not isinstance(identificador_do_anexo, str) or not _ID.fullmatch(
523
+ identificador_do_anexo
524
+ ):
525
+ raise ErroDaGraph("Graph baixar_anexos: anexo sem um id que a Graph reconheça")
526
+ pasta_completa = raiz / destino
527
+ pasta_completa.mkdir(parents=True, exist_ok=True)
528
+ gravado = pasta_completa / _nome_de_arquivo(nome, usados)
529
+ parcial = pasta_completa / (gravado.name + ".parcial")
530
+ try:
531
+ conteudo = cliente.pedir(
532
+ "GET",
533
+ GRAPH,
534
+ f"{base}/{urllib.parse.quote(identificador_do_anexo, safe='')}/$value",
535
+ cabecalhos=_QUALQUER_TIPO,
536
+ para_arquivo=parcial,
537
+ tamanho_max=ARQUIVO_MAX,
538
+ )
539
+ _cliente.checar_leitura("baixar_anexos", conteudo)
540
+ parcial.replace(gravado)
541
+ finally:
542
+ parcial.unlink(missing_ok=True)
543
+ resultado.append(
544
+ {
545
+ "nome": nome,
546
+ "arquivo": str(gravado.relative_to(raiz)),
547
+ "tipo": tipo,
548
+ "tamanho": conteudo.tamanho,
549
+ "anexado": bool(anexar(gravado)),
550
+ }
551
+ )
552
+ return resultado
553
+
554
+ return _lendo("baixar_anexos", ler)
555
+
556
+
557
+ # ── Outlook: enviar ───────────────────────────────────────────────────────────────────────
558
+
559
+
560
+ class _AindaNaoSaiu(Exception):
561
+ """Uma volta do envio falhou antes de despachar o ``send`` (o rascunho não ficou criado, ou
562
+ a resposta dele se perdeu): a volta seguinte procura o rascunho antes de criar outro."""
563
+
564
+
565
+ def _procurar(cliente: Cliente, pasta: str, mid: str, marca: str) -> dict[str, Any] | None:
566
+ """A mensagem de ``pasta`` (``sentitems`` ou ``drafts``) com este ``internetMessageId``."""
567
+ try:
568
+ resposta = cliente.pedir(
569
+ "GET",
570
+ GRAPH,
571
+ f"{RAIZ}/me/mailFolders/{pasta}/messages",
572
+ parametros={
573
+ "$filter": f"internetMessageId eq {literal_odata(mid)}",
574
+ "$select": "id,internetMessageId",
575
+ "$top": "1",
576
+ },
577
+ idempotencia=marca,
578
+ )
579
+ _cliente.checar_leitura("enviar_email", resposta)
580
+ achadas = resposta.json().get("value") or []
581
+ except (ErroDaGraph, OSError) as exc:
582
+ # Sem a busca, não se envia (o token que não veio já é um ``http.NadaSaiu``).
583
+ raise GraphNaoSaiu(f"Graph enviar_email: a busca do identificador falhou: {exc}") from exc
584
+ return achadas[0] if achadas and isinstance(achadas[0], dict) else None
585
+
586
+
587
+ def _criar_rascunho(cliente: Cliente, rascunho: bytes, marca: str) -> str:
588
+ try:
589
+ resposta = cliente.pedir(
590
+ "POST",
591
+ GRAPH,
592
+ f"{RAIZ}/me/messages",
593
+ corpo=rascunho,
594
+ tipo="application/json",
595
+ idempotencia=marca,
596
+ repetir_depois_do_envio=False,
597
+ )
598
+ except OSError as exc:
599
+ raise _AindaNaoSaiu() from exc
600
+ if resposta.status >= 500:
601
+ raise _AindaNaoSaiu()
602
+ _cliente.checar_escrita("enviar_email", resposta)
603
+ try:
604
+ criado = resposta.json().get("id")
605
+ except ErroDaGraph:
606
+ criado = None
607
+ if not isinstance(criado, str) or not _ID.fullmatch(criado):
608
+ # Criou, mas não dá para enviar o que não se identifica: a volta seguinte o acha.
609
+ raise _AindaNaoSaiu()
610
+ return criado
611
+
612
+
613
+ def _despachar_o_envio(cliente: Cliente, mid: str, marca: str, id_do_rascunho: str) -> None:
614
+ """O ``send`` do rascunho. Depois de despachado, nenhuma volta repete: a dúvida confere
615
+ Enviados e, sem achar, é ``GraphIncerto``."""
616
+ try:
617
+ resposta = cliente.pedir(
618
+ "POST",
619
+ GRAPH,
620
+ f"{RAIZ}/me/messages/{urllib.parse.quote(id_do_rascunho, safe='')}/send",
621
+ idempotencia=marca,
622
+ repetir_depois_do_envio=False,
623
+ )
624
+ _cliente.checar_escrita("enviar_email", resposta)
625
+ return
626
+ except GraphIncerto as incerto:
627
+ duvida: Exception = incerto
628
+ except OSError as exc:
629
+ duvida = _cliente.falha_de_escrita("enviar_email", exc)
630
+ if isinstance(duvida, GraphNaoSaiu):
631
+ raise duvida from exc
632
+ # O envio pode ter saído: o que o confirma é a mensagem em Enviados.
633
+ try:
634
+ confirmada = _procurar(cliente, "sentitems", mid, marca)
635
+ except GraphNaoSaiu:
636
+ confirmada = None
637
+ if confirmada is None:
638
+ raise duvida
639
+ return
640
+
641
+
642
+ @PLUGIN.efeito
643
+ def enviar_email(
644
+ chamada: Any,
645
+ para: str | list[str],
646
+ assunto: str,
647
+ corpo: str,
648
+ *,
649
+ cc: str | list[str] | None = None,
650
+ cco: str | list[str] | None = None,
651
+ html: bool = False,
652
+ porta: int | None = None,
653
+ ) -> dict[str, Any]:
654
+ """Envia um e-mail da conta conectada, só dentro de ``item.efetivar()``. ``corpo`` é texto (ou
655
+ HTML, com ``html=True``: o HTML é do robô). Devolve ``internet_id`` (o ``Message-ID``) e
656
+ ``reaproveitado`` (``True`` quando a mensagem desta mesma chamada já estava em Enviados).
657
+
658
+ Não duplica: a mensagem leva o ``Message-ID`` derivado da chave do item, do nome da
659
+ efetivação e da ordem do envio, e antes de criar o rascunho o plugin procura esse
660
+ identificador em Enviados e em Rascunhos. ``GraphNaoSaiu``: nada foi enviado (a Graph
661
+ recusou, a rede não abriu, a busca não respondeu). ``GraphIncerto``: o envio saiu sem
662
+ confirmação e a mensagem não está em Enviados; o plugin não envia de novo."""
663
+ destinatarios = _enderecos(para, "para", obrigatorio=True)
664
+ copia = _enderecos(cc, "cc", obrigatorio=False)
665
+ oculta = _enderecos(cco, "cco", obrigatorio=False)
666
+ if len(destinatarios) + len(copia) + len(oculta) > DESTINATARIOS_MAX:
667
+ raise ValueError(f"no máximo {DESTINATARIOS_MAX} destinatários")
668
+ if not isinstance(assunto, str) or not assunto.strip() or len(assunto) > 255:
669
+ raise ValueError("assunto é um texto de 1 a 255 caracteres")
670
+ if _CONTROLE.search(assunto):
671
+ raise ValueError("assunto não pode ter quebra de linha nem caractere de controle")
672
+ if not isinstance(corpo, str) or len(corpo) > CORPO_MAX:
673
+ raise ValueError(f"corpo é um texto de até {CORPO_MAX} caracteres")
674
+ if not isinstance(html, bool):
675
+ raise ValueError("html é verdadeiro ou falso")
676
+ marca = chamada.idempotencia
677
+ mid = f"<{marca[:TAMANHO_DA_CHAVE]}@{DOMINIO_DO_ID}>"
678
+ rascunho = _json(
679
+ {
680
+ "subject": assunto,
681
+ "body": {"contentType": "HTML" if html else "Text", "content": corpo},
682
+ "toRecipients": _destinatarios(destinatarios),
683
+ "ccRecipients": _destinatarios(copia),
684
+ "bccRecipients": _destinatarios(oculta),
685
+ "internetMessageId": mid,
686
+ }
687
+ )
688
+ cliente = _cliente_da_chamada(chamada, porta)
689
+ for _ in range(_cliente.TENTATIVAS):
690
+ try:
691
+ return _enviar_uma_vez(cliente, mid, marca, rascunho)
692
+ except _AindaNaoSaiu:
693
+ if not cliente.prazo.cabe(0):
694
+ break
695
+ raise GraphNaoSaiu("Graph enviar_email: o rascunho não ficou criado; nada foi enviado")
696
+
697
+
698
+ def _enviar_uma_vez(cliente: Cliente, mid: str, marca: str, rascunho: bytes) -> dict[str, Any]:
699
+ if _procurar(cliente, "sentitems", mid, marca) is not None:
700
+ return {"internet_id": mid, "reaproveitado": True}
701
+ achado = _procurar(cliente, "drafts", mid, marca)
702
+ id_do_rascunho = achado["id"] if achado and isinstance(achado.get("id"), str) else None
703
+ if id_do_rascunho is None:
704
+ id_do_rascunho = _criar_rascunho(cliente, rascunho, marca)
705
+ _despachar_o_envio(cliente, mid, marca, id_do_rascunho)
706
+ return {"internet_id": mid, "reaproveitado": False}
707
+
708
+
709
+ # ── SharePoint ────────────────────────────────────────────────────────────────────────────
710
+
711
+ _CAMPOS_DO_ARQUIVO = "id,name,size,file,folder,lastModifiedDateTime,webUrl"
712
+
713
+
714
+ def _item(dados: dict[str, Any]) -> dict[str, Any]:
715
+ tamanho = dados.get("size")
716
+ pasta = isinstance(dados.get("folder"), dict)
717
+ return {
718
+ "id": dados.get("id", ""),
719
+ "nome": dados.get("name", ""),
720
+ "tipo": "pasta" if pasta else str((dados.get("file") or {}).get("mimeType") or ""),
721
+ "tamanho": None if pasta or not isinstance(tamanho, int) else tamanho,
722
+ "modificado": dados.get("lastModifiedDateTime", ""),
723
+ "url": dados.get("webUrl", ""),
724
+ }
725
+
726
+
727
+ @PLUGIN.operacao
728
+ def listar_arquivos(
729
+ chamada: Any,
730
+ site: str,
731
+ pasta: str = "",
732
+ *,
733
+ limite: int = POR_PAGINA,
734
+ porta: int | None = None,
735
+ ) -> list[dict[str, Any]]:
736
+ """Os itens da ``pasta`` (um caminho como ``Notas/2026``; vazio é a raiz) da biblioteca de
737
+ documentos padrão do ``site`` (``root``, o id ``host,guid,guid`` ou
738
+ ``contoso.sharepoint.com:/sites/Financeiro``), até ``limite``. Cada um: ``id``, ``nome``,
739
+ ``tipo`` (``pasta`` ou o MIME), ``tamanho`` (``None`` na pasta), ``modificado`` e ``url``."""
740
+ limite = _limite(limite, LIMITE_ARQUIVOS_MAX)
741
+ caminho = _no_drive(_site(site), _caminho(pasta, "pasta", vazio=True), depois="/children")
742
+ cliente = _cliente_da_chamada(chamada, porta)
743
+
744
+ def ler() -> list[dict[str, Any]]:
745
+ itens: list[dict[str, Any]] = []
746
+ parametros = {"$select": _CAMPOS_DO_ARQUIVO, "$top": str(min(POR_PAGINA, limite))}
747
+ for dados in _paginas(cliente, "listar_arquivos", caminho, parametros):
748
+ itens.append(_item(dados))
749
+ if len(itens) >= limite:
750
+ break
751
+ return itens
752
+
753
+ return _lendo("listar_arquivos", ler)
754
+
755
+
756
+ def _local_do_download(cliente: Cliente, resposta: _cliente.Resposta, host: str) -> tuple[str, str]:
757
+ """O caminho e a consulta do ``Location`` da Graph, que só vale se for HTTPS no host do
758
+ tenant que o projeto declarou (o link é dado do servidor: nenhum outro host recebe o
759
+ pedido)."""
760
+ destino = resposta.cabecalho("Location")
761
+ partes = urllib.parse.urlsplit(destino)
762
+ porta_aceita = {None, 443, cliente.porta}
763
+ if (
764
+ partes.scheme != "https"
765
+ or partes.username
766
+ or (partes.hostname or "").lower() != host
767
+ or partes.port not in porta_aceita
768
+ ):
769
+ raise ErroDaGraph(
770
+ f"Graph baixar_arquivo: a Graph mandou baixar de {partes.hostname or 'outro lugar'}, "
771
+ f"que não é o {HOST_DO_SHAREPOINT} do projeto ({host})",
772
+ status=resposta.status,
773
+ )
774
+ return partes.path, partes.query
775
+
776
+
777
+ @PLUGIN.operacao
778
+ def baixar_arquivo(
779
+ chamada: Any,
780
+ site: str,
781
+ caminho: str,
782
+ *,
783
+ pasta: str = "sharepoint",
784
+ porta: int | None = None,
785
+ ) -> dict[str, Any]:
786
+ """Grava o arquivo ``caminho`` (``Notas/2026/nota.pdf``) da biblioteca padrão do ``site`` em
787
+ ``<pasta>/<nome>`` no diretório de trabalho (o nome do SharePoint, limpo de separador e
788
+ controle) e devolve ``id``, ``arquivo`` (o caminho gravado), ``tamanho`` e ``tipo``. O
789
+ arquivo só aparece inteiro: o download que cai no meio não deixa arquivo.
790
+
791
+ A Graph responde o conteúdo com um redirecionamento para o host do tenant no SharePoint, que
792
+ vem de ``[config] sharepoint_host`` (e de ``[hosts] permitidos``); o token da conexão não vai
793
+ a esse host, a URL já é autenticada."""
794
+ host = chamada.host_do_projeto(HOST_DO_SHAREPOINT)
795
+ partes = _caminho(caminho, "caminho", vazio=False)
796
+ endereco_do_site = _site(site)
797
+ base = _no_drive(endereco_do_site, partes)
798
+ destino = _pasta_relativa(pasta)
799
+ cliente = _cliente_da_chamada(chamada, porta)
800
+ raiz = Path.cwd().resolve()
801
+
802
+ def ler() -> dict[str, Any]:
803
+ resposta = cliente.pedir("GET", GRAPH, base, parametros={"$select": _CAMPOS_DO_ARQUIVO})
804
+ _cliente.checar_leitura("baixar_arquivo", resposta)
805
+ dados = _item(resposta.json())
806
+ if dados["tipo"] == "pasta":
807
+ raise ErroDaGraph("Graph baixar_arquivo: o caminho é uma pasta, não um arquivo")
808
+ identificador = dados["id"]
809
+ if not isinstance(identificador, str) or not _ID.fullmatch(identificador):
810
+ raise ErroDaGraph("Graph baixar_arquivo: o item veio sem um id que a Graph reconheça")
811
+ pasta_completa = raiz / destino
812
+ pasta_completa.mkdir(parents=True, exist_ok=True)
813
+ gravado = pasta_completa / _nome_de_arquivo(str(dados["nome"]))
814
+ parcial = pasta_completa / (gravado.name + ".parcial")
815
+ conteudo_em = f"{_drive(endereco_do_site)}/items/{identificador}/content"
816
+ try:
817
+ conteudo = cliente.pedir(
818
+ "GET",
819
+ GRAPH,
820
+ conteudo_em,
821
+ cabecalhos=_QUALQUER_TIPO,
822
+ para_arquivo=parcial,
823
+ tamanho_max=ARQUIVO_MAX,
824
+ )
825
+ if conteudo.status in (301, 302, 303, 307, 308):
826
+ caminho_final, consulta = _local_do_download(cliente, conteudo, host)
827
+ conteudo = cliente.pedir(
828
+ "GET",
829
+ host,
830
+ caminho_final,
831
+ consulta_crua=consulta,
832
+ cabecalhos=_QUALQUER_TIPO,
833
+ para_arquivo=parcial,
834
+ tamanho_max=ARQUIVO_MAX,
835
+ com_token=False,
836
+ )
837
+ if not 200 <= conteudo.status < 300:
838
+ _cliente.checar_leitura("baixar_arquivo", conteudo)
839
+ parcial.replace(gravado)
840
+ finally:
841
+ parcial.unlink(missing_ok=True)
842
+ return {
843
+ "id": identificador,
844
+ "arquivo": str(gravado.relative_to(raiz)),
845
+ "tamanho": conteudo.tamanho,
846
+ "tipo": dados["tipo"],
847
+ }
848
+
849
+ return _lendo("baixar_arquivo", ler)
850
+
851
+
852
+ @PLUGIN.efeito
853
+ def enviar_arquivo(
854
+ chamada: Any,
855
+ arquivo: str | os.PathLike[str],
856
+ site: str,
857
+ pasta: str = "",
858
+ *,
859
+ nome: str | None = None,
860
+ existente: str = "substituir",
861
+ chave_no_nome: bool = False,
862
+ porta: int | None = None,
863
+ ) -> dict[str, Any]:
864
+ """Envia um arquivo do diretório de trabalho (até 100 MB) para a ``pasta`` (um caminho como
865
+ ``Comprovantes/2026``; vazio é a raiz) da biblioteca padrão do ``site``, só dentro de
866
+ ``item.efetivar()``, com ``nome`` (o do arquivo, se omitido). Devolve ``id``, ``nome``,
867
+ ``caminho``, ``tamanho`` e ``criado`` (``False`` quando substituiu um arquivo).
868
+
869
+ O caminho é a chave: repetir (retentativa, retomada, refazer) grava o mesmo arquivo, com o
870
+ mesmo conteúdo, e deixa um arquivo só. ``existente="substituir"`` (o padrão) sobrescreve o
871
+ que estiver no caminho; ``existente="falhar"`` recusa o caminho ocupado
872
+ (``ArquivoJaExiste``). ``chave_no_nome=True`` acrescenta ao nome 12 caracteres da chave da
873
+ chamada (``nota.3fa9c1d2b4e6.pdf``), para o arquivo de um item não sobrescrever o de outro.
874
+
875
+ ``GraphNaoSaiu``: nada foi gravado (a Graph recusou, a rede não abriu). ``GraphIncerto``: o
876
+ envio saiu sem confirmação; conferir o arquivo antes de refazer."""
877
+ local = _arquivo_do_workdir(arquivo)
878
+ nome_final = nome if nome is not None else local.name
879
+ if existente not in ("substituir", "falhar"):
880
+ raise ValueError("existente é 'substituir' ou 'falhar'")
881
+ if not isinstance(chave_no_nome, bool):
882
+ raise ValueError("chave_no_nome é verdadeiro ou falso")
883
+ if chave_no_nome:
884
+ raiz_do_nome, extensao = os.path.splitext(str(nome_final))
885
+ nome_final = f"{raiz_do_nome}.{chamada.idempotencia[:TAMANHO_DA_CHAVE_NO_NOME]}{extensao}"
886
+ _segmento(nome_final, "nome")
887
+ partes = [*_caminho(pasta, "pasta", vazio=True), _segmento(nome_final, "nome")]
888
+ dados = local.read_bytes()
889
+ if len(dados) > ENVIO_MAX:
890
+ raise ValueError("arquivo passa de 100 MB: o envio desta versão é de um pedido só")
891
+ caminho = _no_drive(_site(site), partes, depois="/content")
892
+ tipo = mimetypes.guess_type(nome_final)[0] or "application/octet-stream"
893
+ cliente = _cliente_da_chamada(chamada, porta)
894
+ try:
895
+ resposta = cliente.pedir(
896
+ "PUT",
897
+ GRAPH,
898
+ caminho,
899
+ parametros={
900
+ "@microsoft.graph.conflictBehavior": "replace"
901
+ if existente == "substituir"
902
+ else "fail"
903
+ },
904
+ corpo=dados,
905
+ tipo=tipo,
906
+ idempotencia=chamada.idempotencia,
907
+ # Substituir é idempotente (o mesmo caminho, o mesmo conteúdo): repetir depois de uma
908
+ # resposta perdida não muda nada. Falhar não é: a 2ª volta bateria no próprio arquivo.
909
+ repetir_depois_do_envio=existente == "substituir",
910
+ )
911
+ except OSError as exc:
912
+ raise _cliente.falha_de_escrita("enviar_arquivo", exc) from exc
913
+ if resposta.status == 409 and existente == "falhar":
914
+ raise ArquivoJaExiste(
915
+ f"Graph enviar_arquivo: já existe um arquivo nesse caminho ({resposta.status} "
916
+ f"{resposta.codigo})".strip(),
917
+ status=409,
918
+ )
919
+ _cliente.checar_escrita("enviar_arquivo", resposta)
920
+ try:
921
+ criado = resposta.json()
922
+ except ErroDaGraph as exc:
923
+ raise GraphIncerto(
924
+ "Graph enviar_arquivo: a Graph confirmou com um corpo ilegível; o arquivo pode estar "
925
+ "lá",
926
+ status=resposta.status,
927
+ ) from exc
928
+ return {
929
+ "id": criado.get("id", ""),
930
+ "nome": criado.get("name", nome_final),
931
+ "caminho": "/".join(urllib.parse.unquote(p) for p in partes),
932
+ "tamanho": criado.get("size", len(dados)),
933
+ "criado": resposta.status == 201,
934
+ }