Invio di messaggi personalizzati in massa su Microsoft Teams da Excel: guida pratica con Graph API, Power Automate e Outlook

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.

Indice

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

ApproccioCome funzionaProContro / Limiti
Microsoft Graph APIUno 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 OutlookUsi 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 TeamsUn 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 .xlsx con intestazioni Nome, E‑mail, Messaggio.
  • Per gli script: Python 3.9+ con msal, pandas, requests; oppure PowerShell 7+ con modulo Microsoft.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), eventuale ChatMessage.Send se richiesto dal tuo tenant, e User.Read.All per risolvere gli ID utente a partire dall’e‑mail.
  • Applicazione: Chat.ReadWrite.All e 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

  1. Registrare l’app in Entra ID: ottieni Client ID e (per app‑only) Client secret o certificato. Abilita il tipo di account corretto.
  2. Concedere⁄accettare i permessi (delegati o app‑only) e confermare il consenso amministratore se richiesto.
  3. Leggere Excel e normalizzare i dati: rimuovere duplicati⁄spazi, validare gli indirizzi e‑mail.
  4. Creare o recuperare la chat 1:1 per ogni riga (tra il mittente e il destinatario).
  5. Inviare il messaggio con POST /chats/{id}/messages, preferibilmente in contentType: "html" per formattazioni leggere.
  6. 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):

NomeE‑mailMessaggio
Maria Rossimaria.rossi@contoso.comCiao Maria, ecco il tuo promemoria per…
Luca Bianchiluca.bianchi@contoso.comCiao 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=True al primo giro per verificare la resoluzione utenti e la creazione chat senza inviare messaggi.
  • Se i destinatari sono molti, aumenta SLEEP_MS e 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

  1. Trigger: manuale, pianificato o “Quando un file è creato⁄modificato” su OneDrive⁄SharePoint.
  2. Leggi Excel: usa l’azione “List rows present in a table” (definisci prima una Tabella in Excel con colonne Nome, E‑mail, Messaggio).
  3. Ciclo: “Apply to each” sulle righe restituite.
  4. 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.
  5. 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 → MailingsStart 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)

  1. Registra l’app in Entra ID; annota Client ID e (se app‑only) Client secret.
  2. Richiedi⁄concedi i permessi minimi: delegati (Chat.ReadWrite, …) o app (Chat.ReadWrite.All, …).
  3. Prepara messaggi.xlsx con colonne Nome, E‑mail, Messaggio.
  4. Esegui lo script in dry‑run e verifica la risoluzione utenti.
  5. 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

ProblemaCausa probabileSoluzione
Errore 401⁄403 in invioToken scaduto o permessi insufficientiRinnova l’accesso; verifica in Entra ID i permessi effettivi e il consenso admin.
Errore 404 su utenteE‑mail errata o utente esterno⁄guest non abilitatoCorreggi l’indirizzo; limita ai soli utenti interni supportati dalla policy.
429 throttlingTroppi messaggi ravvicinatiImplementa backoff e ritardi; riduci la concorrenza; distribuisci in più batch.
Chat duplicata⁄non trovataRace condition o differenze di membershipControlla prima le chat 1:1 esistenti con espansione membri; in caso di 409 riprova la ricerca.
Messaggi formattati maleHTML non supportato o chiusura tag errataUsa 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

  1. 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).
  2. Ottenere il token: flusso authorization code / device code per agire come utente; client credentials per job applicativo.
  3. Leggere l’Excel: con pandas, Import-Csv o via Graph se il file è su OneDrive⁄SharePoint.
  4. Creare o recuperare la chat 1:1: POST /chats con chatType: "oneOnOne" e members appropriati; in alternativa, cerca tra le chat esistenti dell’utente espandendo i membri.
  5. Inviare il messaggio: POST /chats/{id}/messages con corpo { "body": { "contentType": "html", "content": "<p>...</p>" } }.
  6. Gestire errori e log: registra messageId e chatId; implementa retry su 429 e 5xx.
Indice