1 | # py312 |
2 | # requirements: |
3 | # google-auth==2.62.1 |
4 | # requests==2.34.2 |
5 |
|
6 |
|
7 | import base64 |
8 | import mimetypes |
9 | from email.message import EmailMessage |
10 | from typing import TypedDict |
11 |
|
12 | from google.auth.transport.requests import AuthorizedSession |
13 | from google.oauth2 import service_account as google_sa |
14 |
|
15 |
|
16 | class google_service_account(TypedDict): |
17 | client_email: str |
18 | private_key: str |
19 | token_uri: str |
20 |
|
21 |
|
22 | GMAIL_SEND = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send" |
23 | SCOPE = "https://www.googleapis.com/auth/gmail.send" |
24 |
|
25 |
|
26 | def main( |
27 | service_account: google_service_account, |
28 | delegated_user: str, |
29 | to: str, |
30 | subject: str, |
31 | body: str, |
32 | cc: str = "", |
33 | bcc: str = "", |
34 | send_as: str = "", |
35 | is_html: bool = False, |
36 | thread_id: str = "", |
37 | in_reply_to: str = "", |
38 | attachments: list[dict] = [], |
39 | ): |
40 | """ |
41 | Send Email with Service Account |
42 | Send an email from a Google Workspace mailbox with a service account and domain-wide delegation, without an OAuth connection. `delegated_user` is the mailbox to send from: the service account's client ID must be allowed the https://www.googleapis.com/auth/gmail.send scope in the Admin console (Security > Access and data control > API controls > Domain-wide delegation). Supports cc/bcc, HTML body, `attachments` as text (e.g. CSV) or base64 (`[{filename, content, content_type?}]`: content is text for text/* types and base64 otherwise, type guessed from the filename) and a `send_as` alias of that mailbox (an unverified alias falls back to the primary address). To reply in a thread, pass the original's `thread_id` and its `Message-ID` header as `in_reply_to`, and keep the same subject. |
43 | """ |
44 | s = session(service_account, delegated_user) |
45 | msg = EmailMessage() |
46 | if send_as: |
47 | msg["From"] = send_as |
48 | msg["To"] = to |
49 | if cc: |
50 | msg["Cc"] = cc |
51 | if bcc: |
52 | msg["Bcc"] = bcc |
53 | msg["Subject"] = subject |
54 | if in_reply_to: |
55 | msg["In-Reply-To"] = in_reply_to |
56 | msg["References"] = in_reply_to |
57 | msg.set_content(body, subtype="html" if is_html else "plain") |
58 | for a in attachments or []: |
59 | if not a.get("filename"): |
60 | raise ValueError("each attachment needs a filename") |
61 | ctype = ( |
62 | a.get("content_type") |
63 | or mimetypes.guess_type(a["filename"])[0] |
64 | or "text/plain" |
65 | ) |
66 | maintype, _, subtype = ctype.split(";")[0].strip().partition("/") |
67 | if not subtype: |
68 | raise ValueError(f"{a['filename']}: content_type must be type/subtype") |
69 | content = a.get("content") or "" |
70 | if maintype == "text": |
71 | msg.add_attachment(content, subtype=subtype, filename=a["filename"]) |
72 | else: |
73 | try: |
74 | data = base64.b64decode(content, validate=True) |
75 | except ValueError: |
76 | raise ValueError( |
77 | f"{a['filename']}: {ctype} content must be base64, or set a text/* content_type" |
78 | ) from None |
79 | msg.add_attachment( |
80 | data, |
81 | maintype=maintype, |
82 | subtype=subtype, |
83 | filename=a["filename"], |
84 | ) |
85 |
|
86 | payload = {"raw": base64.urlsafe_b64encode(msg.as_bytes()).decode()} |
87 | if thread_id: |
88 | payload["threadId"] = thread_id |
89 | r = s.post(GMAIL_SEND, json=payload) |
90 | if not r.ok: |
91 | raise Exception(f"{r.status_code} {r.text}") |
92 | return r.json() |
93 |
|
94 |
|
95 | # Domain-wide delegation: google-auth signs the JWT bearer grant for the delegated mailbox |
96 | # and fetches the token on the first request; a new token per run, valid one hour. |
97 | def session(sa: google_service_account, user: str) -> AuthorizedSession: |
98 | if not sa.get("client_email") or not sa.get("private_key"): |
99 | raise ValueError("service_account needs client_email and private_key") |
100 | if not user: |
101 | raise ValueError("delegated_user is required: the Workspace mailbox to send from") |
102 | info = { |
103 | # Drop fields left empty in the form: google-auth would send private_key_id "" as the JWT kid. |
104 | **{k: v for k, v in sa.items() if v}, |
105 | # Keys pasted into a single-line field keep their newlines as literal \n. |
106 | "private_key": sa["private_key"].replace("\\n", "\n"), |
107 | "token_uri": sa.get("token_uri") or "https://oauth2.googleapis.com/token", |
108 | } |
109 | creds = google_sa.Credentials.from_service_account_info( |
110 | info, scopes=[SCOPE] |
111 | ).with_subject(user) |
112 | return AuthorizedSession(creds) |
113 |
|