blob: d643962b99bf9ef03db97ae2cb8379e92b17eb54 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import base64
import email
import email.policy
import io
import json
import urllib.error
from email.message import EmailMessage
from unittest.mock import patch
import pytest
from oauth_draft.create_draft import (
api_get,
api_post,
build_mime,
create_draft,
headers_from_thread,
latest_reply_headers,
main,
parse_args,
read_body,
)
def parse_built_message(raw: bytes) -> EmailMessage:
msg = email.message_from_bytes(raw, policy=email.policy.default)
assert isinstance(msg, EmailMessage)
return msg
def test_build_mime_sets_basic_headers():
raw = build_mime(
from_addr="me@example.com",
to=["a@example.com"],
cc=[],
bcc=[],
subject="Hello",
body="Body content.",
in_reply_to=None,
references=None,
)
msg = parse_built_message(raw)
assert msg["From"] == "me@example.com"
assert msg["To"] == "a@example.com"
assert msg["Subject"] == "Hello"
assert msg["Cc"] is None
assert msg["Bcc"] is None
assert msg["In-Reply-To"] is None
assert msg["References"] is None
assert msg.get_content().rstrip() == "Body content."
def test_build_mime_joins_multiple_recipients():
raw = build_mime(
from_addr="me@example.com",
to=["a@example.com", "b@example.com"],
cc=["cc1@example.com", "cc2@example.com"],
bcc=["bcc@example.com"],
subject="Multi",
body="x",
in_reply_to=None,
references=None,
)
msg = parse_built_message(raw)
assert msg["To"] == "a@example.com, b@example.com"
assert msg["Cc"] == "cc1@example.com, cc2@example.com"
assert msg["Bcc"] == "bcc@example.com"
def test_build_mime_sets_reply_headers_when_provided():
raw = build_mime(
from_addr="me@example.com",
to=["a@example.com"],
cc=[],
bcc=[],
subject="Re: Thing",
body="reply",
in_reply_to="<msg-1@example.com>",
references="<root@example.com> <msg-1@example.com>",
)
msg = parse_built_message(raw)
assert msg["In-Reply-To"] == "<msg-1@example.com>"
assert msg["References"] == "<root@example.com> <msg-1@example.com>"
def test_headers_from_thread_empty_returns_none_pair():
assert headers_from_thread({}) == (None, None)
assert headers_from_thread({"messages": []}) == (None, None)
def test_headers_from_thread_no_message_id_returns_none_pair():
thread = {"messages": [{"payload": {"headers": [{"name": "Subject", "value": "x"}]}}]}
assert headers_from_thread(thread) == (None, None)
def test_headers_from_thread_seeds_references_when_absent():
thread = {
"messages": [
{
"payload": {
"headers": [
{"name": "Message-ID", "value": "<a@example.com>"},
]
}
}
]
}
assert headers_from_thread(thread) == ("<a@example.com>", "<a@example.com>")
def test_headers_from_thread_appends_to_existing_references():
thread = {
"messages": [
{
"payload": {
"headers": [
{"name": "Message-ID", "value": "<root@example.com>"},
]
}
},
{
"payload": {
"headers": [
{"name": "Message-ID", "value": "<reply@example.com>"},
{"name": "References", "value": "<root@example.com>"},
]
}
},
]
}
in_reply_to, references = headers_from_thread(thread)
assert in_reply_to == "<reply@example.com>"
assert references == "<root@example.com> <reply@example.com>"
def test_headers_from_thread_is_case_insensitive_on_header_name():
thread = {
"messages": [
{
"payload": {
"headers": [
{"name": "message-id", "value": "<lower@example.com>"},
]
}
}
]
}
assert headers_from_thread(thread)[0] == "<lower@example.com>"
def test_parse_args_defaults():
args = parse_args(["--to", "x@example.com", "--subject", "S"])
assert args.to == ["x@example.com"]
assert args.cc == []
assert args.bcc == []
assert args.body_file == "-"
assert args.thread_id is None
assert args.no_reply_headers is False
def test_parse_args_repeats():
args = parse_args(
[
"--to",
"a@example.com",
"--to",
"b@example.com",
"--cc",
"c@example.com",
"--subject",
"S",
]
)
assert args.to == ["a@example.com", "b@example.com"]
assert args.cc == ["c@example.com"]
# --- read_body -------------------------------------------------------------
def test_read_body_from_file(tmp_path):
p = tmp_path / "body.txt"
p.write_text("hello from file")
assert read_body(str(p)) == "hello from file"
def test_read_body_from_stdin_when_dash(monkeypatch):
monkeypatch.setattr("sys.stdin", io.StringIO("piped"))
assert read_body("-") == "piped"
def test_read_body_from_stdin_when_none(monkeypatch):
monkeypatch.setattr("sys.stdin", io.StringIO("default"))
assert read_body(None) == "default"
# --- network-mocked helpers ------------------------------------------------
class _FakeResponse:
def __init__(self, payload: bytes):
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return self._payload
def test_api_get_parses_json_response():
with patch("oauth_draft.create_draft.urllib.request.urlopen") as mock_open:
mock_open.return_value = _FakeResponse(b'{"id": "thread-1"}')
result = api_get("token", "/threads/thread-1")
assert result == {"id": "thread-1"}
request = mock_open.call_args.args[0]
assert request.full_url.endswith("/threads/thread-1")
assert request.headers["Authorization"] == "Bearer token"
def test_api_post_parses_json_response():
with patch("oauth_draft.create_draft.urllib.request.urlopen") as mock_open:
mock_open.return_value = _FakeResponse(b'{"id": "draft-7"}')
result = api_post("token", "/drafts", {"message": {"raw": "X"}})
assert result == {"id": "draft-7"}
request = mock_open.call_args.args[0]
assert request.method == "POST"
assert request.headers["Content-type"] == "application/json"
assert json.loads(request.data) == {"message": {"raw": "X"}}
def test_api_post_raises_on_http_error():
err = urllib.error.HTTPError(
url="https://x",
code=403,
msg="Forbidden",
hdrs=None, # type: ignore[arg-type]
fp=io.BytesIO(b'{"error": "forbidden"}'),
)
with patch("oauth_draft.create_draft.urllib.request.urlopen", side_effect=err):
with pytest.raises(SystemExit) as excinfo:
api_post("token", "/drafts", {})
assert "failed (403)" in str(excinfo.value)
assert "forbidden" in str(excinfo.value)
def test_latest_reply_headers_pulls_from_api_get():
fake_thread = {
"messages": [
{
"payload": {
"headers": [
{"name": "Message-ID", "value": "<m@example.com>"},
]
}
}
]
}
with patch("oauth_draft.create_draft.api_get", return_value=fake_thread) as m:
in_reply, refs = latest_reply_headers("token", "thread-id-9")
assert in_reply == "<m@example.com>"
assert refs == "<m@example.com>"
m.assert_called_once_with("token", "/threads/thread-id-9?format=full")
def test_create_draft_payload_includes_threadid_when_set():
raw = b"raw-bytes"
with patch("oauth_draft.create_draft.api_post", return_value={"id": "d-1"}) as m:
result = create_draft("token", "thread-1", raw)
assert result == {"id": "d-1"}
_, _, payload = m.call_args.args
expected_b64 = base64.urlsafe_b64encode(raw).decode().rstrip("=")
assert payload == {"message": {"raw": expected_b64, "threadId": "thread-1"}}
def test_create_draft_payload_omits_threadid_when_none():
with patch("oauth_draft.create_draft.api_post", return_value={"id": "d-2"}) as m:
create_draft("token", None, b"x")
_, _, payload = m.call_args.args
assert "threadId" not in payload["message"]
# --- main ------------------------------------------------------------------
def _make_creds_file(tmp_path):
p = tmp_path / "creds.json"
p.write_text(
json.dumps(
{
"client_id": "cid",
"client_secret": "secret",
"refresh_token": "refresh",
"from_address": "me@example.com",
}
)
)
return p
def test_main_create_draft_end_to_end(tmp_path, capsys):
creds = _make_creds_file(tmp_path)
body_file = tmp_path / "body.txt"
body_file.write_text("Reply body")
api_post_mock = patch(
"oauth_draft.create_draft.api_post",
return_value={
"id": "draft-id-99",
"message": {"id": "msg-id-99", "threadId": "tid"},
},
)
with (
patch("oauth_draft.create_draft.refresh_access_token", return_value="tok"),
patch(
"oauth_draft.create_draft.latest_reply_headers",
return_value=("<a@x>", "<a@x>"),
),
api_post_mock as post,
):
rc = main(
[
"--credentials",
str(creds),
"--thread-id",
"tid",
"--to",
"rcpt@example.com",
"--subject",
"Re: hello",
"--body-file",
str(body_file),
]
)
assert rc == 0
out = capsys.readouterr().out
assert "Draft ID: draft-id-99" in out
assert "Message ID: msg-id-99" in out
# Verify the MIME body posted to /drafts has reply headers + body.
_, path, payload = post.call_args.args
assert path == "/drafts"
raw_b64 = payload["message"]["raw"]
raw_bytes = base64.urlsafe_b64decode(raw_b64 + "=" * (-len(raw_b64) % 4))
decoded = raw_bytes.decode()
assert "From: me@example.com" in decoded
assert "To: rcpt@example.com" in decoded
assert "Subject: Re: hello" in decoded
assert "In-Reply-To: <a@x>" in decoded
assert "Reply body" in decoded
def test_main_no_reply_headers_skips_thread_lookup(tmp_path):
creds = _make_creds_file(tmp_path)
body = tmp_path / "b.txt"
body.write_text("x")
with (
patch("oauth_draft.create_draft.refresh_access_token", return_value="t"),
patch("oauth_draft.create_draft.latest_reply_headers") as latest,
patch(
"oauth_draft.create_draft.api_post",
return_value={"id": "d", "message": {"id": "m"}},
),
):
rc = main(
[
"--credentials",
str(creds),
"--thread-id",
"tid",
"--no-reply-headers",
"--to",
"x@example.com",
"--subject",
"S",
"--body-file",
str(body),
]
)
assert rc == 0
latest.assert_not_called()