01デバイスコードでサインインし、Graph のクライアントを作る確認済
azure-identity の DeviceCodeCredential と msgraph-sdk の GraphServiceClient で、スクリプトから本人として Microsoft Graph を呼ぶ。最初に /me を読み、サインインと同意が通ることを確かめる。
- Microsoft Entra 管理センターでアプリを登録する。リダイレクト URI は空のままにする
- [Authentication]の[Advanced settings]で[Allow public client flows]を[Yes]にして保存する
- クライアント ID とテナント ID を環境変数 CLIENT_ID と TENANT_ID に入れる。コードには書かない
- pip で azure-identity と msgraph-sdk を入れ、スクリプトを実行する
- 表示された URL を開いてコードを入力し、サインインと同意を済ませる
- 表示名が出れば準備は終わり。以後の例は SCOPES だけを変えて同じ作り方を使う
"""Sign in with the device code flow and read your own profile (User.Read)."""
import asyncio
import os
from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.user_item_request_builder import UserItemRequestBuilder
SCOPES = ["User.Read"]
def make_client(scopes):
# Public client app registration; IDs come from environment variables
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
return GraphServiceClient(credentials=cred, scopes=scopes)
async def main():
client = make_client(SCOPES)
query = UserItemRequestBuilder.UserItemRequestBuilderGetQueryParameters(
select=["displayName", "mail", "userPrincipalName"])
me = await client.me.get(request_configuration=RequestConfiguration(query_parameters=query))
print("signed in as:", me.display_name, "/", me.mail or me.user_principal_name)
if __name__ == "__main__":
asyncio.run(main())Python で azure-identity の DeviceCodeCredential と msgraph-sdk の GraphServiceClient を使い、/me の displayName と mail を表示する非同期のスクリプトを書いて。クライアント ID とテナント ID は環境変数 CLIENT_ID と TENANT_ID から読み、権限は User.Read だけにして。
この例のトークンはメモリにだけ置かれ、実行のたびにサインインを求められる。トークンの保存はセキュリティのページで扱う。組織によっては利用者による同意を認めていないので、その場合は管理者に相談する。アプリの登録名と付ける権限は、管理者と決めておく。
出典の該当箇所
Select Authentication under Manage. Locate the Advanced settings section and change the Allow public client flows toggle to Yes, then choose Save.
Include import statements for DeviceCodeCredential from azure.identity and GraphServiceClient from msgraph.graph_service_client to run this code.
Notice that you didn't configure any Microsoft Graph permissions on the app registration. The sample uses dynamic consent to request specific permissions for user authentication.
02$select と $filter で未読メールを差出人ごとに数える確認済
受信トレイの messages に $filter=isRead eq false と $select を付け、件名・差出人・受信日時だけを取る。本文を読まないので、権限は最小の Mail.ReadBasic で足りる。数える処理は関数に分け、見本の JSON で確かめる。
- SCOPES を Mail.ReadBasic にする。この権限では本文・previewBody・添付を読めない
- MessagesRequestBuilderGetQueryParameters に select・filter・top を指定する
- 受信トレイは by_mail_folder_id("inbox") で指定する
- 取得した Message を to_row で辞書に直し、count_by_sender に渡す
- 件数の多い順に表示し、返事の漏れがないかを人が確かめる
"""Count unread Inbox messages per sender using $select, $filter and $top (Mail.ReadBasic)."""
import asyncio
import os
from collections import Counter
from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import (
MessagesRequestBuilder,
)
SCOPES = ["Mail.ReadBasic"] # subject, sender, dates; no body
def to_row(m):
sender = m.from_.email_address.address if m.from_ and m.from_.email_address else ""
return {"from": sender, "subject": m.subject or "", "received": m.received_date_time}
def count_by_sender(rows):
return Counter(r["from"] for r in rows)
async def main():
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
client = GraphServiceClient(credentials=cred, scopes=SCOPES)
query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
select=["subject", "from", "receivedDateTime"],
filter="isRead eq false",
top=50,
)
page = await client.me.mail_folders.by_mail_folder_id("inbox").messages.get(
request_configuration=RequestConfiguration(query_parameters=query))
rows = [to_row(m) for m in page.value or []]
print("unread on the first page:", len(rows))
for sender, n in count_by_sender(rows).most_common(10):
print(n, sender)
if __name__ == "__main__":
asyncio.run(main())実行結果(2026-09-12)
[('[email protected]', 10), ('[email protected]', 8), ('[email protected]', 6)]msgraph-sdk で受信トレイの未読メールを取り、差出人のアドレスごとに件数を数える Python を書いて。$select は subject・from・receivedDateTime だけ、権限は Mail.ReadBasic。数える処理は Graph を呼ばない関数に分けて、messages.json の形の辞書で試せるようにして。
この例は最初のページだけを数える。全件を数えるときはページングの例と組み合わせる。$select に無い項目は None になる。Mail.ReadBasic では本文を取れないので、本文を調べる用途には向かない。差出人のアドレスは個人情報に当たるため、集計結果を渡す範囲を決めておく。
出典の該当箇所
To improve the operation response time, use $select to specify the exact properties you need
Allows the app to read email in the signed-in user's mailbox except body, previewBody, attachments and any extended properties.
GET ~/me/mailFolders/inbox/messages?$filter=isRead eq false
03@odata.nextLink をたどって全件を取り、依頼番号ごとにまとめる確認済
messages の既定のページは 10 件で、$top で 1〜1000 件に変えられる。続きは応答の odata_next_link を with_url にそのまま渡して取る。件名の依頼番号で束ね、件数・返信数・最新の受信日時を CSV に書く。
- $select と $top を付けて最初のページを取る(既定は 10 件)
- 応答に odata_next_link があれば、その URL を with_url(...).get() に渡して次のページを取る
- URL から $skip を取り出して組み立て直さない。公式のページもそう注意している
- MAX_PAGES で取りすぎを止める。大きな $top と多くの項目を組み合わせると HTTP 504 になることがある
- group_by_request で件名の依頼番号(R- で始まる番号)ごとに束ね、out_requests.csv に書く
"""Fetch every Inbox page via @odata.nextLink and group messages by the request ID in the subject."""
import asyncio
import csv
import os
import re
from collections import defaultdict
from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import (
MessagesRequestBuilder,
)
SCOPES = ["Mail.ReadBasic"]
MAX_PAGES = int(os.environ.get("MAX_PAGES", "20"))
REQUEST_ID = re.compile(r"R-\d{4}-\d{3}")
def to_row(m):
return {"subject": m.subject or "", "received": str(m.received_date_time or "")}
def group_by_request(rows):
groups = defaultdict(list)
for r in rows:
hit = REQUEST_ID.search(r["subject"])
groups[hit.group(0) if hit else "(none)"].append(r)
out = []
for rid, items in sorted(groups.items()):
replies = sum(1 for r in items if r["subject"].lower().startswith("re:"))
out.append({"request_id": rid, "messages": len(items), "replies": replies,
"latest": max(r["received"] for r in items)})
return out
async def fetch_all(client):
builder = client.me.mail_folders.by_mail_folder_id("inbox").messages
query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
select=["subject", "receivedDateTime"], top=100)
page = await builder.get(request_configuration=RequestConfiguration(query_parameters=query))
rows, pages = [], 0
while page:
rows += [to_row(m) for m in page.value or []]
pages += 1
if not page.odata_next_link or pages >= MAX_PAGES:
break
# Use the whole nextLink as-is; it already carries $select and $top
page = await builder.with_url(page.odata_next_link).get()
return rows, pages
def write_csv(summary, path="out_requests.csv"):
with open(path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=["request_id", "messages", "replies", "latest"])
w.writeheader()
w.writerows(summary)
async def main():
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
client = GraphServiceClient(credentials=cred, scopes=SCOPES)
rows, pages = await fetch_all(client)
summary = group_by_request(rows)
write_csv(summary)
print("pages:", pages, "messages:", len(rows), "request IDs:", len(summary))
if __name__ == "__main__":
asyncio.run(main())実行結果(2026-09-12)
{'request_id': '(none)', 'messages': 5, 'replies': 0, 'latest': '2026-09-07T17:00:00Z'}
{'request_id': 'R-2026-017', 'messages': 2, 'replies': 0, 'latest': '2026-09-06T20:00:00Z'}
{'request_id': 'R-2026-020', 'messages': 2, 'replies': 0, 'latest': '2026-09-03T01:00:00Z'}
request IDs: 18msgraph-sdk で受信トレイのメールを odata_next_link がなくなるまで取り、件名の R-数字4桁-数字3桁 の依頼番号ごとに件数・Re: で始まる返信の数・最新の受信日時を CSV に書く Python を書いて。取りすぎ防止のページ数の上限を環境変数で変えられるようにして。
件数は取得した時点のものである。msgraph-core にも PageIterator クラスがあるが、保存した公式ページには Python の例が無いため、ここでは nextLink を使った。依頼番号の正規表現は自分の組織の番号の形に合わせる。CSV には件名が入るので、置き場所の権限に注意する。
出典の該当箇所
To get the next page of messages, simply apply the entire URL returned in @odata.nextLink to the next get-messages request.
Don't try to extract the $skip value from the @odata.nextLink URL to manipulate responses.
Use the entire URL in the @odata.nextLink property in a GET request to retrieve the next page of results.
04receivedDateTime の範囲で絞り、現地の日付ごとに受信件数を出す確認済
$filter に receivedDateTime の ge と lt を並べ、指定した期間のメールだけを取る。境界は現地の日付の 0 時を UTC に直して渡す。$orderby に receivedDateTime を使うときは、$filter にも同じ項目を置く。
- START_DATE と END_DATE(YYYY-MM-DD)を環境変数で渡す。無ければ直近 7 日にする
- utc_range で現地の 0 時を UTC に直し、ge と lt の式を作る(終わりの日は含めない)
- orderby に receivedDateTime desc を指定する。$filter にも同じ項目があるので InefficientFilter の誤りにならない
- odata_next_link をたどって期間内のメールを取る
- per_local_day で現地の日付ごとに数えて表示する
"""Count Inbox mail per local day for a receivedDateTime range ($filter ge/lt with $orderby)."""
import asyncio
import os
from collections import Counter
from datetime import date, datetime, time, timedelta, timezone
from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import (
MessagesRequestBuilder,
)
SCOPES = ["Mail.ReadBasic"]
TZ = timezone(timedelta(hours=float(os.environ.get("UTC_OFFSET_HOURS", "9"))))
MAX_PAGES = int(os.environ.get("MAX_PAGES", "20"))
def utc_range(start, end):
"""Local dates [start, end) -> UTC strings for $filter."""
lo = datetime.combine(start, time.min, TZ).astimezone(timezone.utc)
hi = datetime.combine(end, time.min, TZ).astimezone(timezone.utc)
return lo.strftime("%Y-%m-%dT%H:%M:%SZ"), hi.strftime("%Y-%m-%dT%H:%M:%SZ")
def per_local_day(received):
counts = Counter()
for v in received:
if isinstance(v, str):
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
counts[v.astimezone(TZ).date().isoformat()] += 1
return dict(sorted(counts.items()))
def period():
end = date.fromisoformat(os.environ["END_DATE"]) if os.environ.get("END_DATE") else date.today() + timedelta(days=1)
start = date.fromisoformat(os.environ["START_DATE"]) if os.environ.get("START_DATE") else end - timedelta(days=7)
return start, end
async def main():
lo, hi = utc_range(*period())
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
client = GraphServiceClient(credentials=cred, scopes=SCOPES)
builder = client.me.mail_folders.by_mail_folder_id("inbox").messages
query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
select=["receivedDateTime"],
filter="receivedDateTime ge %s and receivedDateTime lt %s" % (lo, hi),
orderby=["receivedDateTime desc"], # same property as in $filter
top=100,
)
page = await builder.get(request_configuration=RequestConfiguration(query_parameters=query))
received, pages = [], 0
while page:
received += [m.received_date_time for m in page.value or []]
pages += 1
if not page.odata_next_link or pages >= MAX_PAGES:
break
page = await builder.with_url(page.odata_next_link).get()
print("period (UTC):", lo, "to", hi, "messages:", len(received))
for day, n in per_local_day(received).items():
print(day, n)
if __name__ == "__main__":
asyncio.run(main())実行結果(2026-09-12)
('2026-08-31T15:00:00Z', '2026-09-07T15:00:00Z')
{'2026-09-01': 3, '2026-09-02': 3, '2026-09-03': 3, '2026-09-04': 4, '2026-09-05': 3, '2026-09-06': 4, '2026-09-07': 3, '2026-09-08': 1}msgraph-sdk で受信トレイから、指定した期間(現地の日付)に受信したメールを $filter の receivedDateTime ge/lt で取り、現地の日付ごとの件数を出す Python を書いて。日付の境界を UTC に直す関数と、日ごとに数える関数は Graph を呼ばない形に分けて。
UTC_OFFSET_HOURS は固定の時差で計算する。夏時間のある地域では zoneinfo で直す。$filter と $orderby の組み合わせを変えると、InefficientFilter の誤りが出ることがある。件数は受信トレイに残っているメールだけで、移動や削除をしたものは入らない。
出典の該当箇所
Properties that appear in $orderby must also appear in $filter.
GET ~/me/mailFolders/inbox/messages?$filter=ReceivedDateTime ge 2017-04-01 and receivedDateTime lt 2017-05-01
Error message: The restriction or sort order is too complex for this operation.
05calendarView で 1 週間の予定を展開し、日ごとの会議時間を足す確認済
calendarView は、指定した期間の中にある定期的な予定の各回・例外・単発の予定を展開して返す。startDateTime と endDateTime を渡し、終日と取り消し済みを除いて日ごとの会議時間を合計する。権限は最小の Calendars.ReadBasic を使う。
- week_range で現地の 0 時から 1 週間を UTC の値にする。時差を付けない値は UTC として解釈される
- CalendarViewRequestBuilderGetQueryParameters に start_date_time・end_date_time・select を指定する
- Prefer: outlook.timezone を付けないと、応答の開始・終了は UTC で返る。ここでは UTC で受けて手元で直す
- odata_next_link をたどって期間内の予定を取る
- hours_per_day で終日と取り消し済みを除き、現地の日付ごとに時間を足す
"""Total meeting hours per local day for one week from calendarView (Calendars.ReadBasic)."""
import asyncio
import os
from collections import defaultdict
from datetime import date, datetime, time, timedelta, timezone
from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.calendar_view.calendar_view_request_builder import (
CalendarViewRequestBuilder,
)
SCOPES = ["Calendars.ReadBasic"]
TZ = timezone(timedelta(hours=float(os.environ.get("UTC_OFFSET_HOURS", "9"))))
MAX_PAGES = int(os.environ.get("MAX_PAGES", "20"))
def week_range(first_day):
"""Local midnight of first_day plus 7 days, as UTC values without an offset."""
lo = datetime.combine(first_day, time.min, TZ).astimezone(timezone.utc)
hi = lo + timedelta(days=7)
return lo.strftime("%Y-%m-%dT%H:%M:%S"), hi.strftime("%Y-%m-%dT%H:%M:%S")
def to_row(e):
return {"subject": e.subject or "", "start": e.start.date_time, "end": e.end.date_time,
"all_day": bool(e.is_all_day), "cancelled": bool(e.is_cancelled)}
def parse_utc(s):
# e.g. 2026-09-14T00:30:00.0000000 (UTC when no Prefer header is sent)
return datetime.fromisoformat(s[:19]).replace(tzinfo=timezone.utc)
def hours_per_day(rows):
hours = defaultdict(float)
for r in rows:
if r["all_day"] or r["cancelled"]:
continue
start, end = parse_utc(r["start"]).astimezone(TZ), parse_utc(r["end"]).astimezone(TZ)
hours[start.date().isoformat()] += (end - start).total_seconds() / 3600
return {d: round(h, 2) for d, h in sorted(hours.items())}
async def main():
first = date.fromisoformat(os.environ.get("START_DATE", date.today().isoformat()))
lo, hi = week_range(first)
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
client = GraphServiceClient(credentials=cred, scopes=SCOPES)
builder = client.me.calendar_view
query = CalendarViewRequestBuilder.CalendarViewRequestBuilderGetQueryParameters(
start_date_time=lo, end_date_time=hi,
select=["subject", "start", "end", "isAllDay", "isCancelled"], top=100)
page = await builder.get(request_configuration=RequestConfiguration(query_parameters=query))
rows, pages = [], 0
while page:
rows += [to_row(e) for e in page.value or []]
pages += 1
if not page.odata_next_link or pages >= MAX_PAGES:
break
page = await builder.with_url(page.odata_next_link).get()
print("events:", len(rows))
for day, h in hours_per_day(rows).items():
print(day, h, "h")
if __name__ == "__main__":
asyncio.run(main())実行結果(2026-09-12)
('2026-09-13T15:00:00', '2026-09-20T15:00:00')
{'2026-09-14': 2.5, '2026-09-15': 1.0, '2026-09-16': 3.5, '2026-09-18': 1.0}msgraph-sdk の calendar_view で、指定した日から 1 週間の予定を取り、終日と取り消し済みを除いて日ごとの会議時間を合計する Python を書いて。権限は Calendars.ReadBasic。時刻は UTC で受け取り、現地時刻に直す部分と合計する部分は Graph を呼ばない関数に分けて。
Calendars.ReadBasic では本文・添付・拡張を読めない。会議の中身まで要るときだけ上位の権限を検討する。日をまたぐ予定は、始まった日にまとめて数える。予定表に入れていない作業は含まれないので、結果は目安として扱う。
出典の該当箇所
Get the occurrences, exceptions, and single instances of events in a calendar view defined by a time range
If no timezone offset is included in the value, it is interpreted as UTC.
Use this to specify the time zone for start and end times in the response. If not specified, those time values are returned in UTC.
Allows the app to read events in user calendars, except for properties such as body, attachments, and extensions.
06messages の delta で、前回からの変更分だけを取り込む確認済
受信トレイの messages/delta を初めて呼ぶと全件が返り、最後のページに @odata.deltaLink が付く。これをファイルに保存し、次回はその URL から呼ぶと、追加・更新・削除の分だけが返る。変更はフォルダーごとに追う。
- 初回は mail_folders.by_mail_folder_id("inbox").messages.delta.get() を $select 付きで呼ぶ
- Prefer: odata.maxpagesize で 1 ページの件数の上限を指定できる
- odata_next_link がある間は with_url で続きを取る。odata_delta_link が来たら、その回の同期は終わり
- odata_delta_link と取り込んだ項目を out_delta_state.json に保存する
- 次回は保存した delta_link を with_url に渡す。@removed の付いた項目は手元の一覧から消す
"""Incremental Inbox sync with messages/delta; the deltaLink is kept in a local state file."""
import asyncio
import json
import os
from pathlib import Path
from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.mail_folders.item.messages.delta.delta_request_builder import (
DeltaRequestBuilder,
)
SCOPES = ["Mail.ReadBasic"]
STATE = Path(os.environ.get("DELTA_STATE", "out_delta_state.json"))
def to_row(m):
if "@removed" in (m.additional_data or {}):
return {"id": m.id, "removed": True}
sender = m.from_.email_address.address if m.from_ and m.from_.email_address else ""
return {"id": m.id, "subject": m.subject or "", "from": sender, "is_read": bool(m.is_read)}
def apply_changes(items, rows):
added = updated = removed = 0
for r in rows:
if r.get("removed"):
removed += 1 if items.pop(r["id"], None) is not None else 0
elif r["id"] in items:
items[r["id"]] = r
updated += 1
else:
items[r["id"]] = r
added += 1
return {"added": added, "updated": updated, "removed": removed}
def load_state():
if STATE.exists():
return json.loads(STATE.read_text(encoding="utf-8"))
return {"delta_link": None, "items": {}}
async def sync(client, state):
builder = client.me.mail_folders.by_mail_folder_id("inbox").messages.delta
if state["delta_link"]:
page = await builder.with_url(state["delta_link"]).get()
else:
query = DeltaRequestBuilder.DeltaRequestBuilderGetQueryParameters(select=["subject", "from", "isRead"])
config = RequestConfiguration(query_parameters=query)
config.headers.add("Prefer", "odata.maxpagesize=50")
page = await builder.get(request_configuration=config)
rows = []
while page:
rows += [to_row(m) for m in page.value or []]
if page.odata_next_link:
page = await builder.with_url(page.odata_next_link).get()
continue
state["delta_link"] = page.odata_delta_link # end of this round
break
return rows
async def main():
state = load_state()
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
client = GraphServiceClient(credentials=cred, scopes=SCOPES)
stats = apply_changes(state["items"], await sync(client, state))
STATE.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
print("changes:", stats, "items kept:", len(state["items"]))
if __name__ == "__main__":
asyncio.run(main())実行結果(2026-09-12)
{'added': 24, 'updated': 0, 'removed': 0}
{'added': 0, 'updated': 1, 'removed': 1} kept: 23msgraph-sdk の messages.delta で受信トレイを同期する Python を書いて。初回は全件を取り、odata_delta_link を JSON ファイルに保存し、次回はその URL から変更分だけを取る。@removed の付いた項目は手元の辞書から消し、追加・更新・削除の件数を表示して。権限は Mail.ReadBasic。
delta はフォルダー単位の操作で、フォルダーの階層をまとめて追うことはできない。保存した delta_link はそのメールボックスの読み取りに使えるので、他人と共有しない。状態のファイルを消すと、次回は全件の取り直しになる。@removed の reason を見て、手元の扱いを決める。
出典の該当箇所
Delta query is a per-folder operation. To track the changes of the messages in a folder hierarchy, you need to track each folder individually.
signifies that the current round of change tracking is complete.
the request header, Prefer: odata.maxpagesize={x}, to set the maximum number of messages in a response.
07未読の集計を自分宛てにメールする(既定は DRY_RUN)確認済
未読メールを差出人ごとに数え、その結果を本文にして sendMail で送る。既定の DRY_RUN では送らずに、宛先と本文を表示するだけにする。送るときだけ DRY_RUN=0 と宛先の REPORT_TO を指定する。
- SCOPES は読み取りの Mail.ReadBasic と送信の Mail.Send の 2 つにする
- 未読メールを取り、build_summary で本文の文字列を作る
- DRY_RUN の既定は 1。表示された宛先と本文を確かめる
- 送るときは REPORT_TO に宛先を入れ、DRY_RUN=0 で実行する
- SendMailPostRequestBody に Message を入れて me.send_mail.post に渡す。成功すると 202 Accepted が返る
"""Mail yourself a summary of unread Inbox mail; DRY_RUN by default (Mail.ReadBasic + Mail.Send)."""
import asyncio
import os
from collections import Counter
from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.models.body_type import BodyType
from msgraph.generated.models.email_address import EmailAddress
from msgraph.generated.models.item_body import ItemBody
from msgraph.generated.models.message import Message
from msgraph.generated.models.recipient import Recipient
from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import (
MessagesRequestBuilder,
)
from msgraph.generated.users.item.send_mail.send_mail_post_request_body import SendMailPostRequestBody
SCOPES = ["Mail.ReadBasic", "Mail.Send"]
DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
REPORT_TO = os.environ.get("REPORT_TO", "")
def build_summary(rows):
counts = Counter(r["from"] for r in rows)
lines = ["Unread messages: %d" % len(rows), ""]
lines += ["%3d %s" % (n, sender) for sender, n in counts.most_common(10)]
return "\n".join(lines)
async def main():
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
client = GraphServiceClient(credentials=cred, scopes=SCOPES)
query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
select=["from"], filter="isRead eq false", top=100)
page = await client.me.mail_folders.by_mail_folder_id("inbox").messages.get(
request_configuration=RequestConfiguration(query_parameters=query))
rows = [{"from": m.from_.email_address.address if m.from_ and m.from_.email_address else ""}
for m in page.value or []]
text = build_summary(rows)
if DRY_RUN or not REPORT_TO:
print("DRY_RUN: would send to", REPORT_TO or "(set REPORT_TO)")
print(text)
return
body = SendMailPostRequestBody(
message=Message(
subject="Unread mail summary",
body=ItemBody(content_type=BodyType.Text, content=text),
to_recipients=[Recipient(email_address=EmailAddress(address=REPORT_TO))],
),
save_to_sent_items=True,
)
await client.me.send_mail.post(body) # 202 Accepted: queued, not yet delivered
print("sent to", REPORT_TO)
if __name__ == "__main__":
asyncio.run(main())実行結果(2026-09-12)
Unread messages: 24
10 [email protected]
8 [email protected]
6 [email protected]msgraph-sdk で未読メールを差出人ごとに数え、その一覧を本文にしたメールを send_mail で送る Python を書いて。環境変数 DRY_RUN が 0 でない限り送らずに宛先と本文を表示するだけにし、宛先は REPORT_TO から読んで。本文を作る部分は Graph を呼ばない関数に分けて。
202 Accepted は受け付けたという意味で、配信が終わったことは示さない。送ったメールは既定で[送信済みアイテム]に保存される。Mail.Send があれば、Mail.ReadWrite が無くても送信できる。送った後は取り消せないので、DRY_RUN の表示で宛先を確かめてから送る。自動で送るメールの宛先と頻度は、組織の規程に合わせる。
出典の該当箇所
If successful, this method returns 202 Accepted response code. It doesn't return anything in the response body.
Note: A 202 Accepted response code indicates that the request has been accepted; however, it doesn't indicate that the request processing has completed.
With the Mail.Send permission, an app can send mail and save a copy to the user's Sent Items folder, even if the app isn't granted the Mail.ReadWrite or Mail.ReadWrite.Shared permission.