Vuoi recapitare messaggi diversi a molti colleghi come chat 1:1 in Microsoft Teams partendo da un foglio Excel? In questa guida trovi approcci, pro⁄contro, criteri di scelta e istruzioni operative (con script pronti all’uso) per automatizzare l’invio senza copia‑incolla.
Scenario e obiettivo
Hai un file Excel con tre colonne (Nome, E‑mail, Messaggio) e desideri spedire a ciascun destinatario un testo personalizzato direttamente in chat privata su Microsoft Teams, con piena tracciabilità e possibilità di rispondere nella stessa conversazione. Il requisito chiave è evitare operazioni manuali ripetitive e avere un processo ripetibile, verificabile e sicuro.
Confronto rapido degli approcci
| Approccio | Come funziona | Pro | Contro / Limiti |
|---|---|---|---|
| Microsoft Graph API | Uno script legge Excel, scorre le righe, crea o recupera la chat 1:1 e chiama l’endpoint POST /chats/{chat-id}/messages con il testo personalizzato. | Invio diretto in chat 1:1 in Teams; massima automazione; logging degli ID messaggio; gestione errori⁄retry; estendibile (allegati, formattazione HTML, ecc.). | Richiede un’app registrata in Entra ID (Azure AD) e gestione dell’autenticazione (OAuth 2.0). Serve un minimo di scripting (PowerShell, Python o simili). |
| Mail Merge (Stampa unione) di Outlook | Usi Excel come origine dati e invii e‑mail personalizzate con Word⁄Outlook. | Nessuna codifica; tutto da interfaccia; ideale se è accettabile l’e‑mail al posto della chat. | Non è Teams: la conversazione non vive in chat; niente cronologia⁄notifiche in Teams. |
| Power Automate + connettore Teams | Un flow legge il file (OneDrive⁄SharePoint) e per ogni riga invia un’azione “Chat message”. | Nessun codice; integrazione nativa M365; buona per volumi moderati. | Flessibilità e logging inferiori a Graph; lettura⁄gestione risposte richiede logiche aggiuntive; alcune azioni variano per licenza⁄tenant. |
Criteri di scelta
- Hai accesso ad Entra ID e un minimo di scripting? Scegli Graph API per potenza e controllo.
- Ti basta l’e‑mail? La Stampa unione di Outlook è immediata.
- Vuoi restare no‑code dentro M365? Valuta Power Automate con connettore Teams (accettando i limiti di tracciabilità e gestione risposta).
Soluzione consigliata: Graph API end‑to‑end
Di seguito un percorso operativo completo per inviare chat 1:1 personalizzate da Excel usando Microsoft Graph. Troverai anche script di esempio sia Python (MSAL + richieste REST) sia PowerShell (Microsoft Graph PowerShell SDK).
Prerequisiti tecnici
- Account con diritti per registrare un’app in Entra ID (ex Azure AD) o un amministratore che approva i permessi.
- Microsoft Teams e Microsoft Graph abilitati nel tenant.
- File Excel
.xlsxcon intestazioni Nome, E‑mail, Messaggio. - Per gli script: Python 3.9+ con
msal,pandas,requests; oppure PowerShell 7+ con moduloMicrosoft.Graph.
Permessi e modelli di autenticazione
Puoi operare in modalità delegata (lo script agisce a nome dell’utente che esegue, con consenso) o in modalità applicazione (daemon⁄job con permessi app‑only). In base alla scelta, i permessi tipici includono:
- Delegati:
Chat.ReadWrite(per creare⁄leggere chat e inviare messaggi), eventualeChatMessage.Sendse richiesto dal tuo tenant, eUser.Read.Allper risolvere gli ID utente a partire dall’e‑mail. - Applicazione:
Chat.ReadWrite.Alle permessi directory per risoluzione utenti (User.Read.All). In alcuni tenant può essere necessaria una application access policy / approvazioni specifiche da parte dell’amministratore di Teams.
Nota: i nomi esatti dei permessi disponibili⁄consentiti possono variare nel tempo e per policy. Coinvolgi sempre l’amministratore per la scelta minima sufficiente al tuo scenario.
Flusso tecnico
- Registrare l’app in Entra ID: ottieni Client ID e (per app‑only) Client secret o certificato. Abilita il tipo di account corretto.
- Concedere⁄accettare i permessi (delegati o app‑only) e confermare il consenso amministratore se richiesto.
- Leggere Excel e normalizzare i dati: rimuovere duplicati⁄spazi, validare gli indirizzi e‑mail.
- Creare o recuperare la chat 1:1 per ogni riga (tra il mittente e il destinatario).
- Inviare il messaggio con
POST /chats/{id}/messages, preferibilmente incontentType: "html"per formattazioni leggere. - Gestire errori e limiti (HTTP 4xx⁄5xx, 429 throttling con Retry-After) e loggare gli ID dei messaggi.
Struttura del file Excel
Crea un foglio con intestazioni esatte (rispettando maiuscole⁄minuscole se vuoi riferirti ai nomi di colonna nel codice):
| Nome | E‑mail | Messaggio |
|---|---|---|
| Maria Rossi | maria.rossi@contoso.com | Ciao Maria, ecco il tuo promemoria per… |
| Luca Bianchi | luca.bianchi@contoso.com | Ciao Luca, grazie per l’adesione a… |
Esempio completo in Python (delegated via Device Code)
Questo script legge messaggi.xlsx e invia messaggi 1:1 dal tuo account (token delegato). Richiede: pip install msal pandas requests openpyxl. Assicurati che l’app registrata consenta i flussi public client / device code.
import time
import json
import pandas as pd
import requests
import msal
from pathlib import Path
==== CONFIGURAZIONE ====
TENANTID = "YOURTENANT_ID" # es. "contoso.onmicrosoft.com" o GUID
CLIENTID = "YOURPUBLICCLIENTID"
SCOPES = ["User.Read", "Chat.ReadWrite", "User.Read.All", "offline_access"] # aggiungi ChatMessage.Send se richiesto dal tenant
EXCEL_PATH = "messaggi.xlsx"
DRY_RUN = False # True = non invia, stampa solo cosa farebbe
SLEEP_MS = 250 # pausa tra i messaggi per cortesia verso i limiti
GRAPH = "[https://graph.microsoft.com/v1.0](https://graph.microsoft.com/v1.0)"
def gettokendevice_code():
app = msal.PublicClientApplication(CLIENTID, authority=f"[https://login.microsoftonline.com/{TENANTID}](https://login.microsoftonline.com/{TENANT_ID})")
flow = app.initiatedeviceflow(scopes=SCOPES)
if "user_code" not in flow:
raise RuntimeError("Impossibile avviare il device flow. Controlla Client ID e permessi.")
print(flow["message"]) # segui le istruzioni a schermo per autenticarti
result = app.acquiretokenbydeviceflow(flow)
if "access_token" not in result:
raise RuntimeError(f"Autenticazione fallita: {result}")
return result["access_token"]
def resolveuseridbyemail(token, email):
r = requests.get(f"{GRAPH}/users/{email}", headers={"Authorization": f"Bearer {token}"})
if r.status_code == 200:
return r.json()["id"]
fallback: filter
r = requests.get(f"{GRAPH}/users?$filter=mail eq '{email}' or userPrincipalName eq '{email}'", headers={"Authorization": f"Bearer {token}"})
r.raiseforstatus()
values = r.json().get("value", [])
if not values:
raise ValueError(f"Utente non trovato: {email}")
return values[0]["id"]
def get_me(token):
r = requests.get(f"{GRAPH}/me", headers={"Authorization": f"Bearer {token}"})
r.raiseforstatus()
return r.json()
def findexistingoneononechat(token, meid, other_id):
Lista le chat dell'utente e trova quella 1:1 che contiene entrambi i membri
url = f"{GRAPH}/me/chats?$filter=chatType eq 'oneOnOne'&$expand=members($select=userId)"
r = requests.get(url, headers={"Authorization": f"Bearer {token}"})
r.raiseforstatus()
for chat in r.json().get("value", []):
member_ids = {m.get("userId") for m in chat.get("members", []) if m.get("userId")}
if {meid, otherid}.issubset(member_ids):
return chat["id"]
return None
def createoneononechat(token, meid, otherid):
body = {
"chatType": "oneOnOne",
"members": [
{
"@odata.type": "#microsoft.graph.aadUserConversationMember",
"roles": ["owner"],
"[user@odata.bind](mailto:user@odata.bind)": f"{GRAPH}/users('{me_id}')"
},
{
"@odata.type": "#microsoft.graph.aadUserConversationMember",
"roles": ["owner"],
"[user@odata.bind](mailto:user@odata.bind)": f"{GRAPH}/users('{other_id}')"
}
]
}
r = requests.post(f"{GRAPH}/chats", headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, data=json.dumps(body))
if r.status_code in (200, 201):
return r.json()["id"]
if r.status_code == 409:
chat già esistente: prova a cercarla
return None
r.raiseforstatus()
def sendmessage(token, chatid, html_message):
body = {
"body": {
"contentType": "html",
"content": html_message
}
}
r = requests.post(f"{GRAPH}/chats/{chat_id}/messages",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
data=json.dumps(body))
if r.status_code in (200, 201):
return r.json()["id"]
if r.status_code == 429:
retry = int(r.headers.get("Retry-After", "1"))
print(f"Throttled. Attendo {retry}s e riprovo...")
time.sleep(retry)
return sendmessage(token, chatid, html_message)
r.raiseforstatus()
def main():
token = gettokendevice_code()
me = get_me(token)
me_id = me["id"]
print(f"Mittente: {me.get('displayName')} ({me.get('userPrincipalName')})")
```
df = pd.read_excel(EXCEL_PATH).fillna("")
required_columns = {"Nome", "E-mail", "Messaggio"}
if not required_columns.issubset(df.columns):
raise ValueError(f"Colonne richieste mancanti. Trovate: {list(df.columns)}")
total = len(df)
print(f"Righe da elaborare: {total}")
sent_log = []
for idx, row in df.iterrows():
name = str(row["Nome"]).strip()
email = str(row["E-mail"]).strip()
message = str(row["Messaggio"]).strip()
if not email or not message:
print(f"[{idx+1}/{total}] SKIP: E-mail o Messaggio vuoti")
continue
try:
other_id = resolve_user_id_by_email(token, email)
chat_id = find_existing_one_on_one_chat(token, me_id, other_id)
if not chat_id:
chat_id = create_one_on_one_chat(token, me_id, other_id)
if not chat_id:
# in rari casi di 409, riprova a cercare
chat_id = find_existing_one_on_one_chat(token, me_id, other_id)
if not chat_id:
print(f"[{idx+1}/{total}] ERRORE: impossibile ottenere chat 1:1 per {email}")
continue
# opzionale: personalizza ulteriormente sostituendo segnaposti nel testo
html = message.replace("{Nome}", name)
if DRY_RUN:
print(f"[{idx+1}/{total}] DRY-RUN verso {email} (chat {chat_id}): {html[:60]}...")
continue
msg_id = send_message(token, chat_id, html)
sent_log.append({"email": email, "chat_id": chat_id, "message_id": msg_id})
print(f"[{idx+1}/{total}] OK {email} (msgId: {msg_id})")
time.sleep(SLEEP_MS / 1000.0)
except Exception as ex:
print(f"[{idx+1}/{total}] ERRORE con {email}: {ex}")
# scrivi log
if sent_log:
import csv
with open("invii_teams_log.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["email", "chat_id", "message_id"])
writer.writeheader()
writer.writerows(sent_log)
print("Log scritto su invii_teams_log.csv")
```
if name == "main":
main()
Consigli pratici per Python
- Imposta
DRY_RUN=Trueal primo giro per verificare la resoluzione utenti e la creazione chat senza inviare messaggi. - Se i destinatari sono molti, aumenta
SLEEP_MSe considera di inserire una coda⁄batching; gestisci sempre il 429. - Per messaggi ricchi, mantieni
contentType: "html"ma evita HTML pesante; preferisci grassetto, liste, link testuali.
Esempio in PowerShell (Graph PowerShell SDK, delegato)
Questo script usa il modulo Microsoft Graph e chiama gli endpoint REST con Invoke-MgGraphRequest per aderire alla sintassi più vicina alla documentazione Graph.
# Requisiti:
Install-Module Microsoft.Graph -Scope CurrentUser
Excel in CSV UTF-8 con separatore virgola o usa Import-Excel se hai ImportExcel
$CsvPath = "messaggi.csv" # colonne: Nome,E-mail,Messaggio
$DryRun = $true
$SleepMs = 250
Connect-MgGraph -Scopes "User.Read.All","Chat.ReadWrite","ChatMessage.Send","offline_access"
$me = Invoke-MgGraphRequest -Method GET -Uri "[https://graph.microsoft.com/v1.0/me](https://graph.microsoft.com/v1.0/me)"
function Resolve-UserId {
param([string]$Email)
$u = Invoke-MgGraphRequest -Method GET -Uri ("[https://graph.microsoft.com/v1.0/users/{0}](https://graph.microsoft.com/v1.0/users/{0})" -f $Email) -ErrorAction SilentlyContinue
if ($u) { return $u.id }
$q = "[https://graph.microsoft.com/v1.0/users?`$filter=mail](https://graph.microsoft.com/v1.0/users?`$filter=mail) eq '{0}' or userPrincipalName eq '{0}'" -f $Email
$res = Invoke-MgGraphRequest -Method GET -Uri $q
if ($res.value.Count -gt 0) { return $res.value[0].id }
throw "Utente non trovato: $Email"
}
function Get-ExistingChatId {
param([string]$MeId, [string]$OtherId)
$url = "[https://graph.microsoft.com/v1.0/me/chats?`$filter=chatType](https://graph.microsoft.com/v1.0/me/chats?`$filter=chatType) eq 'oneOnOne'&`$expand=members(`$select=userId)"
$res = Invoke-MgGraphRequest -Method GET -Uri $url
foreach ($chat in $res.value) {
$members = @()
foreach ($m in $chat.members) { if ($m.userId) { $members += $m.userId } }
if ($members -contains $MeId -and $members -contains $OtherId) {
return $chat.id
}
}
return $null
}
function New-OneOnOneChat {
param([string]$MeId, [string]$OtherId)
$body = @{
chatType = "oneOnOne"
members = @(
@{
"@odata.type" = "#microsoft.graph.aadUserConversationMember"
roles = @("owner")
"[user@odata.bind](mailto:user@odata.bind)" = "[https://graph.microsoft.com/v1.0/users('$MeId](https://graph.microsoft.com/v1.0/users%28'$MeId)')"
},
@{
"@odata.type" = "#microsoft.graph.aadUserConversationMember"
roles = @("owner")
"[user@odata.bind](mailto:user@odata.bind)" = "[https://graph.microsoft.com/v1.0/users('$OtherId](https://graph.microsoft.com/v1.0/users%28'$OtherId)')"
}
)
} | ConvertTo-Json -Depth 10
$r = Invoke-MgGraphRequest -Method POST -Uri "[https://graph.microsoft.com/v1.0/chats](https://graph.microsoft.com/v1.0/chats)" -Body $body
return $r.id
}
function Send-ChatMessage {
param([string]$ChatId, [string]$Html)
$body = @{
body = @{
contentType = "html"
content = $Html
}
} | ConvertTo-Json -Depth 4
$uri = "[https://graph.microsoft.com/v1.0/chats/{0}/messages](https://graph.microsoft.com/v1.0/chats/{0}/messages)" -f $ChatId
$r = Invoke-MgGraphRequest -Method POST -Uri $uri -Body $body -ErrorAction Stop
return $r.id
}
$meId = $me.id
$rows = Import-Csv -Path $CsvPath
$i = 0
foreach ($row in $rows) {
$i++
$name = $row.Nome.Trim()
$email = $row.'E-mail'.Trim()
$msg = $row.Messaggio
```
if (-not $email -or -not $msg) {
Write-Host "[$i/$($rows.Count)] SKIP: E-mail o Messaggio mancanti"
continue
}
try {
$otherId = Resolve-UserId -Email $email
$chatId = Get-ExistingChatId -MeId $meId -OtherId $otherId
if (-not $chatId) { $chatId = New-OneOnOneChat -MeId $meId -OtherId $otherId }
$html = $msg -replace "\{Nome\}", $name
if ($DryRun) {
Write-Host "[$i/$($rows.Count)] DRY-RUN verso $email (chat $chatId): $($html.Substring(0,[Math]::Min(60,$html.Length)))..."
} else {
$mid = Send-ChatMessage -ChatId $chatId -Html $html
Write-Host "[$i/$($rows.Count)] OK $email (msgId: $mid)"
}
Start-Sleep -Milliseconds $SleepMs
}
catch {
Write-Warning "[$i/$($rows.Count)] ERRORE con $email: $($_.Exception.Message)"
}
```
}
Gestione errori, limiti e conformità
- 429 Throttling: implementa sempre un backoff (rispetta l’header Retry-After). Evita burst elevati; preferisci batch da 50–100 con piccole pause.
- Destinatari inesistenti o disattivati: gestisci 404/400, logga l’indirizzo e continua con gli altri.
- Timeout⁄transienti (5xx): riprova con backoff esponenziale (es. 1s, 2s, 4s, max 30s).
- Privacy & consenso: invia solo a destinatari interni e nel rispetto delle policy aziendali; evita contenuti sensibili se non necessari.
- Audit: registra chatId e messageId per ogni invio. Conserva i log in area sicura (SharePoint, storage protetto).
Formattazione messaggi
Il corpo del messaggio supporta HTML leggero; ecco pattern utili:
- Segnaposto: inserisci
{Nome}nel testo e sostituiscilo in fase di invio. - Bullet: usa
<ul><li>...</li></ul>. - Link: incolla URL completi; evita HTML complesso⁄inline CSS.
Alternativa no‑code: Power Automate
Se non vuoi scrivere codice, un flow è una buona soluzione per volumi medi e requisiti standard.
Progettazione del flow
- Trigger: manuale, pianificato o “Quando un file è creato⁄modificato” su OneDrive⁄SharePoint.
- Leggi Excel: usa l’azione “List rows present in a table” (definisci prima una Tabella in Excel con colonne Nome, E‑mail, Messaggio).
- Ciclo: “Apply to each” sulle righe restituite.
- Invio messaggio: azione del connettore Teams per postare in “Chat con utente” (a seconda dell’ambiente: come utente o come Flow bot). Passa il campo E‑mail come destinatario.
- Logging: compila un array di risultati (email, esito) e scrivilo su un file CSV⁄SharePoint al termine.
Limitazioni pratiche
- La creazione automatica della nuova chat 1:1 potrebbe non essere disponibile in tutti i connettori⁄licenze. Se manca, pre‑crea la chat o passa a Graph.
- La traccia delle risposte degli utenti non è nativa: serve un trigger aggiuntivo su messaggi in arrivo o una logica con Graph⁄webhook.
- Gestisci i limiti di esecuzione e concorrenza del flow (imposta degree of parallelism con cautela per evitare throttling).
Quando basta l’e‑mail: stampa unione Outlook
Se il requisito è solo informativo e la chat non è fondamentale, la stampa unione consente di inviare e‑mail personalizzate a partire da Excel senza scrivere codice. Lato operativo: prepara il foglio, avvia Word → Mailings → Start Mail Merge, collega l’origine dati, compila il corpo e invia tramite Outlook. Ricorda che questa strada non genera una chat Teams e non consente di rispondere nella stessa conversazione.
Checklist di avvio rapido (Graph)
- Registra l’app in Entra ID; annota Client ID e (se app‑only) Client secret.
- Richiedi⁄concedi i permessi minimi: delegati (Chat.ReadWrite, …) o app (Chat.ReadWrite.All, …).
- Prepara
messaggi.xlsxcon colonne Nome, E‑mail, Messaggio. - Esegui lo script in dry‑run e verifica la risoluzione utenti.
- Esegui l’invio reale e conserva il log generato.
Best practice di delivery
- Segmenta i destinatari in batch (es. per reparto⁄lingua) per controllare timing e volume.
- Orari: programma l’invio in fasce di minore congestione; evita orari sensibili.
- Tono: personalizza il saluto con il nome e chiarisci subito l’azione richiesta.
- Accessibilità: usa frasi brevi, elenchi puntati, evita allegati pesanti; linka risorse interne se servono.
- Re‑invii: se serve un promemoria, annota i messageId e pianifica follow‑up solo a chi non ha risposto.
Troubleshooting
| Problema | Causa probabile | Soluzione |
|---|---|---|
| Errore 401⁄403 in invio | Token scaduto o permessi insufficienti | Rinnova l’accesso; verifica in Entra ID i permessi effettivi e il consenso admin. |
| Errore 404 su utente | E‑mail errata o utente esterno⁄guest non abilitato | Correggi l’indirizzo; limita ai soli utenti interni supportati dalla policy. |
| 429 throttling | Troppi messaggi ravvicinati | Implementa backoff e ritardi; riduci la concorrenza; distribuisci in più batch. |
| Chat duplicata⁄non trovata | Race condition o differenze di membership | Controlla prima le chat 1:1 esistenti con espansione membri; in caso di 409 riprova la ricerca. |
| Messaggi formattati male | HTML non supportato o chiusura tag errata | Usa HTML minimale; valida i tag; evita tabelle complesse⁄CSS inline. |
Estensioni evolute
- Message Extension per Teams: un pannello in chat per importare l’Excel e inviare con UX integrata; adatto se l’uso è ricorrente e vuoi zero script per gli utenti finali.
- OneDrive⁄SharePoint + Graph: leggi direttamente il file online via Graph (Drive API) evitando copie locali; ottimo per scenari orchestrati.
- Allegati e rich media: possibile con Graph, ma valuta la complessità (caricamento⁄share link e reference negli attachments del messaggio).
Domande frequenti
Posso inviare a guest o utenti esterni?
Dipende dalle impostazioni del tenant e dalle policy di chat con esterni. In genere per un primo progetto mantieni il perimetro interno.
Qual è il limite di lunghezza del messaggio?
Evita testi molto lunghi: meglio link a una pagina interna e un riassunto in 3–5 righe.
Posso inviare come “app” e non come utente?
Sì, con permessi applicazione e policy adeguate; richiede più governance e l’approvazione di un amministratore.
Template di progetto
- Obiettivo: inviare comunicazione personalizzata a N utenti (Nome, E‑mail, Messaggio) in chat 1:1 su Teams.
- Sicurezza: principio del privilegio minimo; niente dati sensibili in chiaro; log protetti.
- Qualità: test su gruppo pilota (10 utenti); approvazione legale⁄HR se il contenuto riguarda policy aziendali.
- Runbook: dry‑run, invio batch A, verifica, invio batch B; monitoraggio errori; report finale di consegna.
Conclusioni
Per inviare messaggi personalizzati in massa direttamente nelle chat 1:1 di Microsoft Teams la via più potente e controllabile è Microsoft Graph API: dalla preparazione del file Excel alla creazione⁄ricerca della chat e all’invio con logging, hai un flusso ripetibile e auditabile. Power Automate offre un’alternativa no‑code rapida per esigenze standard, mentre la stampa unione di Outlook è la scelta più veloce se è sufficiente l’e‑mail. Con gli esempi di questa guida puoi partire subito in sicurezza, mantenendo governance e qualità dell’esperienza per i destinatari.
Appendice: Passi minimi per partire con Graph API
- Preparare Entra ID: registra un’app e assegna i permessi adeguati (es. Chat.ReadWrite / Chat.ReadWrite.All, ChatMessage.Send dove richiesto; User.Read.All per risolvere gli utenti).
- Ottenere il token: flusso authorization code / device code per agire come utente; client credentials per job applicativo.
- Leggere l’Excel: con
pandas,Import-Csvo via Graph se il file è su OneDrive⁄SharePoint. - Creare o recuperare la chat 1:1:
POST /chatsconchatType: "oneOnOne"emembersappropriati; in alternativa, cerca tra le chat esistenti dell’utente espandendo i membri. - Inviare il messaggio:
POST /chats/{id}/messagescon corpo{ "body": { "contentType": "html", "content": "<p>...</p>" } }. - Gestire errori e log: registra messageId e chatId; implementa retry su 429 e 5xx.
