0

Send Email with Service Account

by
Published today

Send an email from a Google Workspace mailbox with a service account and domain-wide delegation (gmail.send), without an OAuth connection. Supports cc/bcc, HTML body, attachments as text (e.g. CSV) or base64, a send-as alias and thread replies.

Script gmail Verified

The script

Submitted by hugo989 Python3
Verified 1 day ago
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