From daf6de2cf905bb78b4a99c5f15a89988f901e65c Mon Sep 17 00:00:00 2001 From: Yann-CV Date: Tue, 27 Oct 2026 16:57:17 +0100 Subject: [PATCH 1/4] contact-researchers: add the Thunderbird account signature to IMAP drafts A draft opened from the drafts folder doesn't get the account signature from Thunderbird, so mailbox.py now appends the signature of the Thunderbird identity matching OUTREACH_EMAIL (HTML or plain text, with Thunderbird's "-- " separator). An HTML signature makes the draft multipart/alternative. OUTREACH_THUNDERBIRD_PROFILE picks the profile, or disables the signature with "none". Co-Authored-By: Claude Opus 5.5 --- skills/contact-researchers/SKILL.md | 29 +++- skills/contact-researchers/scripts/mailbox.py | 154 +++++++++++++++++- 2 files changed, 172 insertions(+), 11 deletions(-) diff --git a/skills/contact-researchers/SKILL.md b/skills/contact-researchers/SKILL.md index 5673882..e67403d 100644 --- a/skills/contact-researchers/SKILL.md +++ b/skills/contact-researchers/SKILL.md @@ -9,7 +9,8 @@ description: Draft personalized outreach emails introducing Goldener to Gmail draft in the connected mailbox, a draft in any IMAP mailbox set through optional `OUTREACH_*` environment variables — host, email address, password — or `.eml` files in a local directory), plus optional sender - details. Before drafting, it checks that + details. An IMAP draft gets the signature of the matching Thunderbird + account when one is configured. Before drafting, it checks that no email was ever exchanged with that address (skipping the researcher when one was). Each email is casual, in short sentences, and never reads as a sales pitch: it @@ -82,6 +83,15 @@ themselves. | `OUTREACH_PASSWORD_COMMAND` | instead of `OUTREACH_PASSWORD`: a command printing the password from a vault, run without a shell, e.g. `rbw get goldener-outreach` or `bw get password goldener-outreach` (Bitwarden), `pass show goldener-outreach`, `op read op://Private/goldener-outreach/password` (1Password), `secret-tool lookup service goldener-outreach` | | `OUTREACH_BW_SESSION` | Bitwarden CLI session key (`bw unlock --raw`), passed to the command as `BW_SESSION` | | `OUTREACH_DRAFTS_FOLDER` | drafts folder [the folder the server flags `\Drafts`, else `Drafts`] | + | `OUTREACH_THUNDERBIRD_PROFILE` | Thunderbird profile directory holding the signature of the `OUTREACH_EMAIL` account [the profiles in the usual places: `~/.thunderbird`, snap, flatpak, macOS]; `none` stores drafts without it | + + **Account signature**: a draft opened from the drafts folder doesn't get + the account's signature from the mail client, so `mailbox.py draft` + appends it itself: the signature of the Thunderbird account whose address + is `OUTREACH_EMAIL` (HTML or plain text, after a `-- ` line unless the + account suppresses it; an HTML signature makes the draft plain text + + HTML). `mailbox.py config` reports it as `signature` (its source, or + `null` when none is found). `mailbox.py` reads them from its own env file — `~/.config/goldener-outreach/.env`, or the file `OUTREACH_ENV_FILE` @@ -114,7 +124,9 @@ themselves. them as given; never invent a sender name, title or affiliation — leave a `[Your name]` placeholder in the signature when none is given, and say so. **No signature** (optional): when the user says their mail tool already - adds a signature (or asks for none), the email ends with the closing word + adds a signature (or asks for none), and always for an IMAP draft when + `mailbox.py config` reports a `signature` (the account's signature is + appended to the draft), the email ends with the closing word only (`Cheers,`), without the name and role lines, and no `[Your name]` placeholder; the `From:` display name is still set when a name is given. @@ -312,7 +324,8 @@ through a hosting or sharing feature. role in the project from the sender details, e.g. `Yann Chéné Leroy` then `Goldener (non-profit, open source)` (`[Your name]` placeholder when no name is given). With **no signature** (input 4), stop at - `Cheers,`: the mail tool appends its own signature. + `Cheers,`: the mail tool, or `mailbox.py` for an IMAP draft, appends + the account's signature. - The user's additions (event, link, feature) where they fit, as given. No bullet lists, no attachment. @@ -323,7 +336,9 @@ through a hosting or sharing feature. `Yann Chéné Leroy `, RFC 2047-encoded by Python's `email` package), so the recipient sees a person, not a bare address, then `python3 -I /scripts/mailbox.py draft `, which - appends it to the drafts folder flagged `\Draft`. Report the folder. + appends it to the drafts folder flagged `\Draft`, with the account's + Thunderbird signature when one is found. Report the folder and the + `signature` it printed. - **Gmail**: `create_draft` with the researcher's address as the only `to`, the user's cc/bcc if given, the subject and the plain-text body; then apply the user's label to the new draft if one was asked for. Keep @@ -344,7 +359,11 @@ through a hosting or sharing feature. the introduction relies on, and the subject line. Show the full text of one draft (all of them when there are three or fewer). Then list any ambiguity left for the user, placeholders to fill (e.g. `[Your name]`), - and anything noteworthy found in the repository or PR content. Remind + and anything noteworthy found in the repository or PR content. When the + Thunderbird signature links remote images (e.g. a logo), say that + Thunderbird blocks them in stored drafts until the user allows remote + content from the `OUTREACH_EMAIL` address (Options in the "remote + content" bar). Remind the user that nothing was sent. ## Notes diff --git a/skills/contact-researchers/scripts/mailbox.py b/skills/contact-researchers/scripts/mailbox.py index 4e6fea5..6911642 100644 --- a/skills/contact-researchers/scripts/mailbox.py +++ b/skills/contact-researchers/scripts/mailbox.py @@ -23,6 +23,11 @@ the password command only, as BW_SESSION OUTREACH_DRAFTS_FOLDER drafts folder (default: the folder flagged \\Drafts, else "Drafts") + OUTREACH_THUNDERBIRD_PROFILE + Thunderbird profile directory holding the signature of the + OUTREACH_EMAIL account (default: the profiles found in the usual + places: ~/.thunderbird, snap, flatpak, macOS); "none" disables + the signature The env file holds one KEY=VALUE per line (`export ` prefix, quotes around the value, blank lines and `#` comments allowed); only OUTREACH_* keys are read. It should be @@ -30,8 +35,8 @@ Usage: mailbox.py config - Print the settings in use (the password is shown only as set / not set) and - test the login. + Print the settings in use (the password is shown only as set / not set), the + Thunderbird signature found for OUTREACH_EMAIL (or null), and test the login. mailbox.py check ADDRESS Search every folder, read-only, for messages to, from, cc or bcc ADDRESS, and stop with an error if any folder can't be searched. Print {address, total, @@ -41,14 +46,23 @@ none. ADDRESS `-` reads the address from stdin. mailbox.py draft FILE.eml Append FILE.eml to the drafts folder with the \\Draft and \\Seen flags, and - print {folder, from} as JSON. The From header is set to OUTREACH_EMAIL when - the file has none. This script never sends anything: it has no SMTP code. + print {folder, from, signature} as JSON. The From header is set to + OUTREACH_EMAIL when the file has none. When the Thunderbird account of + OUTREACH_EMAIL has a signature, it is appended to the plain-text body as + Thunderbird does (after a "-- " line unless the account suppresses it); an HTML + signature makes the draft multipart/alternative (plain text + HTML), so it + opens in Thunderbird's HTML editor with the signature. This script never sends + anything: it has no SMTP code. """ import email +import email.message import email.policy import email.utils import imaplib import functools +import glob +import html +import html.parser import json import os import re @@ -220,6 +234,125 @@ def check(address): "others": len(seen - in_drafts), "latest": latest}, ensure_ascii=False)) +THUNDERBIRD_PROFILE_GLOBS = ( + "~/.thunderbird/*/prefs.js", + "~/snap/thunderbird/current/.thunderbird/*/prefs.js", + "~/snap/thunderbird/current/.config/thunderbird/*/prefs.js", + "~/.var/app/org.mozilla.Thunderbird/.thunderbird/*/prefs.js", + "~/Library/Thunderbird/Profiles/*/prefs.js", +) +PREF_RE = re.compile(r'^user_pref\("(?P[^"]+)",\s*(?P"(?:[^"\\]|\\.)*"|true|false|-?\d+)\);', re.M) + + +def read_prefs(path): + prefs = {} + with open(path, encoding="utf-8", errors="replace") as f: + for m in PREF_RE.finditer(f.read()): + try: + prefs[m.group("key")] = json.loads(m.group("value")) + except ValueError: + continue + return prefs + + +@functools.cache +def signature(): + """{"text", "html" (or None), "separator", "source"} of the OUTREACH_EMAIL account's + Thunderbird signature, or None when there is none.""" + profile = os.environ.get("OUTREACH_THUNDERBIRD_PROFILE", "") + if profile.lower() == "none": + return None + sender = os.environ.get("OUTREACH_EMAIL", "").lower() + if profile: + candidates = [os.path.join(os.path.expanduser(profile), "prefs.js")] + else: + candidates = [p for g in THUNDERBIRD_PROFILE_GLOBS for p in glob.glob(os.path.expanduser(g))] + # The most recently written profile wins when several hold the account. + for path in sorted(candidates, key=lambda p: os.path.getmtime(p) if os.path.exists(p) else 0, + reverse=True): + if not os.path.isfile(path): + continue + prefs = read_prefs(path) + for key, value in prefs.items(): + m = re.fullmatch(r"mail\.identity\.([^.]+)\.useremail", key) + if not m or str(value).lower() != sender: + continue + ident = f"mail.identity.{m.group(1)}." + sig_file = prefs.get(ident + "sig_file", "") + if prefs.get(ident + "attach_signature") and sig_file and os.path.isfile(sig_file): + with open(sig_file, encoding="utf-8", errors="replace") as f: + raw = f.read() + is_html = sig_file.lower().endswith((".htm", ".html")) + else: + raw = prefs.get(ident + "htmlSigText", "") + is_html = bool(prefs.get(ident + "htmlSigFormat")) + if not str(raw).strip(): + return None + if is_html: + sig_html = re.sub(r"", "", raw, flags=re.S).strip() + sig_text = html_to_text(sig_html) + else: + sig_html, sig_text = None, raw.strip("\n") + return {"text": sig_text, "html": sig_html, + "separator": not prefs.get(ident + "suppress_signature_separator"), + "source": f"{path} ({m.group(1)}, {'HTML' if is_html else 'plain text'})"} + return None + + +class _TextExtractor(html.parser.HTMLParser): + BLOCKS = {"br", "div", "p", "tr", "table", "li", "h1", "h2", "h3", "h4", "h5", "h6"} + + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts, self.skip = [], 0 + + def handle_starttag(self, tag, attrs): + if tag in ("style", "script", "head"): + self.skip += 1 + elif tag in self.BLOCKS: + self.parts.append("\n") + elif tag == "td": + self.parts.append(" ") + + def handle_endtag(self, tag): + if tag in ("style", "script", "head"): + self.skip = max(0, self.skip - 1) + elif tag in self.BLOCKS: + self.parts.append("\n") + + def handle_data(self, data): + if not self.skip: + self.parts.append(re.sub(r"\s+", " ", data)) + + +def html_to_text(source): + parser = _TextExtractor() + parser.feed(source) + lines = (re.sub(r"[ \xa0]+", " ", line).strip() for line in "".join(parser.parts).splitlines()) + return "\n".join(line for line in lines if line) + + +def with_signature(msg, sig): + """A copy of the text/plain message `msg` with the signature appended to its body.""" + body = msg.get_content().rstrip("\n") + sep = "-- \n" if sig["separator"] else "" + out = email.message.EmailMessage(policy=email.policy.SMTP) + for key, value in msg.items(): + if key.lower() not in ("mime-version", "content-type", "content-transfer-encoding"): + out[key] = value + out.set_content(f"{body}\n\n{sep}{sig['text']}\n", cte="8bit") + if sig["html"]: + paragraphs = "".join(f"

{html.escape(p).replace(chr(10), '
')}

\n" + for p in re.split(r"\n\s*\n", body) if p.strip()) + sep_html = "--
\n" if sig["separator"] else "" + out.add_alternative( + "\n\n\n" + f"{paragraphs}
{sep_html}{sig['html']}
\n" + "\n", subtype="html", cte="8bit") + return out + + def draft(path): with open(path, "rb") as f: msg = email.message_from_binary_file(f, policy=email.policy.SMTP) @@ -228,6 +361,12 @@ def draft(path): sender = os.environ.get("OUTREACH_EMAIL", "") if not msg["From"]: msg["From"] = sender + sig = signature() + if sig: + if msg.get_content_type() != "text/plain" or msg.is_multipart(): + sys.exit(f"{path} is not a plain-text email: can't append the signature " + "(set OUTREACH_THUNDERBIRD_PROFILE=none to store it without one)") + msg = with_signature(msg, sig) conn = connect() box = drafts_folder(conn) typ, data = conn.append(box, "(\\Draft \\Seen)", imaplib.Time2Internaldate(time.time()), @@ -235,7 +374,8 @@ def draft(path): conn.logout() if typ != "OK": sys.exit(f"IMAP APPEND to {box} failed: {data}") - print(json.dumps({"folder": box.strip('"'), "from": str(msg["From"])}, ensure_ascii=False)) + print(json.dumps({"folder": box.strip('"'), "from": str(msg["From"]), + "signature": sig["source"] if sig else None}, ensure_ascii=False)) def main(): @@ -251,7 +391,9 @@ def main(): print(json.dumps({"env_file": env_file, "host": host, "port": port, "email": user, "password": "from OUTREACH_PASSWORD" if password()[1] == "variable" else "from OUTREACH_PASSWORD_COMMAND", - "drafts_folder": box.strip('"'), "login": "ok"})) + "drafts_folder": box.strip('"'), + "signature": signature()["source"] if signature() else None, + "login": "ok"}, ensure_ascii=False)) elif sys.argv[1] == "check": # `-` reads the address from stdin, so it never goes through a shell command line. address = sys.stdin.readline() if sys.argv[2] == "-" else sys.argv[2] From 6e1270005d9ae51ee8514d3bbdaf70f890048cc4 Mon Sep 17 00:00:00 2001 From: Yann-CV Date: Tue, 27 Oct 2026 17:01:16 +0100 Subject: [PATCH 2/4] contact-researchers: embed the signature's images in IMAP drafts Thunderbird blocks remote images in stored drafts, so the signature's logo (a link) didn't show. Download the signature's remote images and embed them as cid: parts (multipart/related), and keep the signature HTML exactly as Thunderbird stores it. Co-Authored-By: Claude Opus 5.5 --- skills/contact-researchers/SKILL.md | 12 ++--- skills/contact-researchers/scripts/mailbox.py | 48 +++++++++++++++++-- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/skills/contact-researchers/SKILL.md b/skills/contact-researchers/SKILL.md index e67403d..6fbc558 100644 --- a/skills/contact-researchers/SKILL.md +++ b/skills/contact-researchers/SKILL.md @@ -90,7 +90,8 @@ themselves. appends it itself: the signature of the Thunderbird account whose address is `OUTREACH_EMAIL` (HTML or plain text, after a `-- ` line unless the account suppresses it; an HTML signature makes the draft plain text + - HTML). `mailbox.py config` reports it as `signature` (its source, or + HTML, with its remote images, e.g. a logo, downloaded and embedded in the + draft, since Thunderbird blocks linked images in stored drafts). `mailbox.py config` reports it as `signature` (its source, or `null` when none is found). `mailbox.py` reads them from its own env file — @@ -359,11 +360,10 @@ through a hosting or sharing feature. the introduction relies on, and the subject line. Show the full text of one draft (all of them when there are three or fewer). Then list any ambiguity left for the user, placeholders to fill (e.g. `[Your name]`), - and anything noteworthy found in the repository or PR content. When the - Thunderbird signature links remote images (e.g. a logo), say that - Thunderbird blocks them in stored drafts until the user allows remote - content from the `OUTREACH_EMAIL` address (Options in the "remote - content" bar). Remind + and anything noteworthy found in the repository or PR content. Report any + signature image `mailbox.py` couldn't embed (its warning): Thunderbird + blocks that linked image in stored drafts until the user allows remote + content from the `OUTREACH_EMAIL` address. Remind the user that nothing was sent. ## Notes diff --git a/skills/contact-researchers/scripts/mailbox.py b/skills/contact-researchers/scripts/mailbox.py index 6911642..8da6f6d 100644 --- a/skills/contact-researchers/scripts/mailbox.py +++ b/skills/contact-researchers/scripts/mailbox.py @@ -51,7 +51,9 @@ OUTREACH_EMAIL has a signature, it is appended to the plain-text body as Thunderbird does (after a "-- " line unless the account suppresses it); an HTML signature makes the draft multipart/alternative (plain text + HTML), so it - opens in Thunderbird's HTML editor with the signature. This script never sends + opens in Thunderbird's HTML editor with the signature; its remote images + (e.g. a logo) are downloaded and embedded as cid: parts, since Thunderbird + blocks remote images in stored drafts. This script never sends anything: it has no SMTP code. """ import email @@ -70,6 +72,7 @@ import subprocess import sys import time +import urllib.request from email.header import decode_header, make_header DEFAULT_ENV_FILE = "~/.config/goldener-outreach/.env" @@ -288,17 +291,50 @@ def signature(): is_html = bool(prefs.get(ident + "htmlSigFormat")) if not str(raw).strip(): return None + images = [] if is_html: - sig_html = re.sub(r"", "", raw, flags=re.S).strip() - sig_text = html_to_text(sig_html) + sig_text = html_to_text(raw) + sig_html, images = embed_images(raw.strip()) else: sig_html, sig_text = None, raw.strip("\n") - return {"text": sig_text, "html": sig_html, + return {"text": sig_text, "html": sig_html, "images": images, "separator": not prefs.get(ident + "suppress_signature_separator"), "source": f"{path} ({m.group(1)}, {'HTML' if is_html else 'plain text'})"} return None +IMG_SRC_RE = re.compile(r'(]*?\bsrc\s*=\s*)(["\'])(https?://[^"\']+)\2', re.I | re.S) + + +def embed_images(sig_html): + """(html, [(cid, bytes, maintype, subtype)]): the signature's remote images downloaded and + pointed to as cid: parts, since Thunderbird blocks remote images in stored drafts. An image + that can't be downloaded keeps its link.""" + images, by_url = [], {} + + def replace(m): + url = html.unescape(m.group(3)) + if url not in by_url: + try: + with urllib.request.urlopen(url, timeout=30) as resp: + data, ctype = resp.read(), resp.headers.get_content_type() + except (OSError, ValueError) as e: + print(f"warning: signature image {url} not embedded ({e}); kept as a link", + file=sys.stderr) + return m.group(0) + if not ctype.startswith("image/"): + print(f"warning: signature image {url} is {ctype}; kept as a link", file=sys.stderr) + return m.group(0) + # The sender's domain, not the default (the local host name), so the id leaks nothing. + domain = os.environ.get("OUTREACH_EMAIL", "").partition("@")[2] or "localhost" + cid = email.utils.make_msgid("signature", domain=domain)[1:-1] + images.append((cid, data, *ctype.split("/", 1))) + by_url[url] = cid + return f'{m.group(1)}{m.group(2)}cid:{by_url[url]}{m.group(2)}' + + return IMG_SRC_RE.sub(replace, sig_html), images + + class _TextExtractor(html.parser.HTMLParser): BLOCKS = {"br", "div", "p", "tr", "table", "li", "h1", "h2", "h3", "h4", "h5", "h6"} @@ -350,6 +386,10 @@ def with_signature(msg, sig): "content=\"text/html; charset=UTF-8\">\n\n" f"{paragraphs}
{sep_html}{sig['html']}
\n" "\n", subtype="html", cte="8bit") + html_part = out.get_payload()[1] + for cid, data, maintype, subtype in sig["images"]: + html_part.add_related(data, maintype=maintype, subtype=subtype, cid=f"<{cid}>", + disposition="inline", filename=f"signature.{subtype}") return out From ceb43122bdfe9d78a0ec720fc5d33a5416c5101b Mon Sep 17 00:00:00 2001 From: Yann-CV Date: Tue, 27 Oct 2026 17:22:53 +0100 Subject: [PATCH 3/4] contact-researchers: give IMAP drafts a Date and Message-ID Without a Date header, Thunderbird lists the draft as undated, away from the recent drafts, so new drafts looked missing. Co-Authored-By: Claude Opus 5.5 --- skills/contact-researchers/scripts/mailbox.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/skills/contact-researchers/scripts/mailbox.py b/skills/contact-researchers/scripts/mailbox.py index 8da6f6d..2496912 100644 --- a/skills/contact-researchers/scripts/mailbox.py +++ b/skills/contact-researchers/scripts/mailbox.py @@ -47,7 +47,8 @@ mailbox.py draft FILE.eml Append FILE.eml to the drafts folder with the \\Draft and \\Seen flags, and print {folder, from, signature} as JSON. The From header is set to - OUTREACH_EMAIL when the file has none. When the Thunderbird account of + OUTREACH_EMAIL, and Date and Message-ID to now and a new id, when the file + has none. When the Thunderbird account of OUTREACH_EMAIL has a signature, it is appended to the plain-text body as Thunderbird does (after a "-- " line unless the account suppresses it); an HTML signature makes the draft multipart/alternative (plain text + HTML), so it @@ -401,6 +402,11 @@ def draft(path): sender = os.environ.get("OUTREACH_EMAIL", "") if not msg["From"]: msg["From"] = sender + # Without a Date, mail clients list the draft out of place (Thunderbird sorts it as undated). + if not msg["Date"]: + msg["Date"] = email.utils.formatdate(localtime=True) + if not msg["Message-ID"]: + msg["Message-ID"] = email.utils.make_msgid(domain=sender.partition("@")[2] or None) sig = signature() if sig: if msg.get_content_type() != "text/plain" or msg.is_multipart(): From 3e48dbfab1ae372463da1ce9dc3793fa1e55f2f7 Mon Sep 17 00:00:00 2001 From: Yann-CV Date: Tue, 27 Oct 2026 19:38:44 +0100 Subject: [PATCH 4/4] contact-researchers: address review of the Thunderbird signature - find snap profiles under ~/snap/thunderbird/common too - resolve the portable sig_file-rel path; a missing attached signature file means no signature, not the stale htmlSigText - download signature images only when building a draft (not in config), capped at 5 MiB each - replace empty Date / Message-ID headers instead of adding a second one - document which images get embedded, and store IMAP drafts with OUTREACH_THUNDERBIRD_PROFILE=none when the user asks for no signature Co-Authored-By: Claude Opus 5.5 --- skills/contact-researchers/SKILL.md | 16 +++++-- skills/contact-researchers/scripts/mailbox.py | 46 ++++++++++++++----- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/skills/contact-researchers/SKILL.md b/skills/contact-researchers/SKILL.md index 6fbc558..55a162a 100644 --- a/skills/contact-researchers/SKILL.md +++ b/skills/contact-researchers/SKILL.md @@ -83,7 +83,7 @@ themselves. | `OUTREACH_PASSWORD_COMMAND` | instead of `OUTREACH_PASSWORD`: a command printing the password from a vault, run without a shell, e.g. `rbw get goldener-outreach` or `bw get password goldener-outreach` (Bitwarden), `pass show goldener-outreach`, `op read op://Private/goldener-outreach/password` (1Password), `secret-tool lookup service goldener-outreach` | | `OUTREACH_BW_SESSION` | Bitwarden CLI session key (`bw unlock --raw`), passed to the command as `BW_SESSION` | | `OUTREACH_DRAFTS_FOLDER` | drafts folder [the folder the server flags `\Drafts`, else `Drafts`] | - | `OUTREACH_THUNDERBIRD_PROFILE` | Thunderbird profile directory holding the signature of the `OUTREACH_EMAIL` account [the profiles in the usual places: `~/.thunderbird`, snap, flatpak, macOS]; `none` stores drafts without it | + | `OUTREACH_THUNDERBIRD_PROFILE` | Thunderbird profile directory holding the signature of the `OUTREACH_EMAIL` account [the profiles in the usual places: `~/.thunderbird`, snap (`~/snap/thunderbird/common/.thunderbird`, `~/snap/thunderbird/current/.config/thunderbird`), flatpak, macOS]; `none` stores drafts without it | **Account signature**: a draft opened from the drafts folder doesn't get the account's signature from the mail client, so `mailbox.py draft` @@ -91,8 +91,10 @@ themselves. is `OUTREACH_EMAIL` (HTML or plain text, after a `-- ` line unless the account suppresses it; an HTML signature makes the draft plain text + HTML, with its remote images, e.g. a logo, downloaded and embedded in the - draft, since Thunderbird blocks linked images in stored drafts). `mailbox.py config` reports it as `signature` (its source, or - `null` when none is found). + draft when written as `` (up to 5 MiB each), since + Thunderbird blocks linked images in stored drafts; other images are kept + as is). `mailbox.py config` reports it as `signature` (its source, or + `null` when none is found), without downloading its images. `mailbox.py` reads them from its own env file — `~/.config/goldener-outreach/.env`, or the file `OUTREACH_ENV_FILE` @@ -130,6 +132,9 @@ themselves. appended to the draft), the email ends with the closing word only (`Cheers,`), without the name and role lines, and no `[Your name]` placeholder; the `From:` display name is still set when a name is given. + When the user asks for no signature at all, IMAP drafts are stored with + `OUTREACH_THUNDERBIRD_PROFILE=none` so the account's signature isn't + appended either. ## Safety @@ -338,8 +343,9 @@ through a hosting or sharing feature. `email` package), so the recipient sees a person, not a bare address, then `python3 -I /scripts/mailbox.py draft `, which appends it to the drafts folder flagged `\Draft`, with the account's - Thunderbird signature when one is found. Report the folder and the - `signature` it printed. + Thunderbird signature when one is found (prefix the command with + `OUTREACH_THUNDERBIRD_PROFILE=none` when the user asked for no + signature at all). Report the folder and the `signature` it printed. - **Gmail**: `create_draft` with the researcher's address as the only `to`, the user's cc/bcc if given, the subject and the plain-text body; then apply the user's label to the new draft if one was asked for. Keep diff --git a/skills/contact-researchers/scripts/mailbox.py b/skills/contact-researchers/scripts/mailbox.py index 2496912..6741366 100644 --- a/skills/contact-researchers/scripts/mailbox.py +++ b/skills/contact-researchers/scripts/mailbox.py @@ -26,7 +26,8 @@ OUTREACH_THUNDERBIRD_PROFILE Thunderbird profile directory holding the signature of the OUTREACH_EMAIL account (default: the profiles found in the usual - places: ~/.thunderbird, snap, flatpak, macOS); "none" disables + places: ~/.thunderbird, snap (common and current), flatpak, + macOS); "none" disables the signature The env file holds one KEY=VALUE per line (`export ` prefix, quotes around the value, @@ -53,8 +54,9 @@ Thunderbird does (after a "-- " line unless the account suppresses it); an HTML signature makes the draft multipart/alternative (plain text + HTML), so it opens in Thunderbird's HTML editor with the signature; its remote images - (e.g. a logo) are downloaded and embedded as cid: parts, since Thunderbird - blocks remote images in stored drafts. This script never sends + (e.g. a logo; with a quoted absolute http(s) URL, up to 5 MiB each) + are downloaded and embedded as cid: parts, since Thunderbird blocks remote + images in stored drafts; any other image is kept as is. This script never sends anything: it has no SMTP code. """ import email @@ -240,6 +242,7 @@ def check(address): THUNDERBIRD_PROFILE_GLOBS = ( "~/.thunderbird/*/prefs.js", + "~/snap/thunderbird/common/.thunderbird/*/prefs.js", "~/snap/thunderbird/current/.thunderbird/*/prefs.js", "~/snap/thunderbird/current/.config/thunderbird/*/prefs.js", "~/.var/app/org.mozilla.Thunderbird/.thunderbird/*/prefs.js", @@ -282,8 +285,12 @@ def signature(): if not m or str(value).lower() != sender: continue ident = f"mail.identity.{m.group(1)}." - sig_file = prefs.get(ident + "sig_file", "") - if prefs.get(ident + "attach_signature") and sig_file and os.path.isfile(sig_file): + if prefs.get(ident + "attach_signature"): + sig_file = signature_file(prefs, ident, os.path.dirname(path)) + if not sig_file: + print(f"warning: the signature file of {sender} in {path} is missing; " + "drafts get no signature", file=sys.stderr) + return None with open(sig_file, encoding="utf-8", errors="replace") as f: raw = f.read() is_html = sig_file.lower().endswith((".htm", ".html")) @@ -292,18 +299,26 @@ def signature(): is_html = bool(prefs.get(ident + "htmlSigFormat")) if not str(raw).strip(): return None - images = [] if is_html: - sig_text = html_to_text(raw) - sig_html, images = embed_images(raw.strip()) + sig_html, sig_text = raw.strip(), html_to_text(raw) else: sig_html, sig_text = None, raw.strip("\n") - return {"text": sig_text, "html": sig_html, "images": images, + return {"text": sig_text, "html": sig_html, "separator": not prefs.get(ident + "suppress_signature_separator"), "source": f"{path} ({m.group(1)}, {'HTML' if is_html else 'plain text'})"} return None +def signature_file(prefs, ident, profile_dir): + """The identity's attached signature file, from its portable `sig_file-rel` ("[ProfD]..." + relative to the profile) or its absolute `sig_file`, or None when neither exists.""" + rel = prefs.get(ident + "sig_file-rel", "") + paths = [os.path.join(profile_dir, rel[len("[ProfD]"):])] if rel.startswith("[ProfD]") else [] + paths.append(prefs.get(ident + "sig_file", "")) + return next((p for p in paths if p and os.path.isfile(p)), None) + + +MAX_IMAGE_BYTES = 5 * 1024 * 1024 IMG_SRC_RE = re.compile(r'(]*?\bsrc\s*=\s*)(["\'])(https?://[^"\']+)\2', re.I | re.S) @@ -318,7 +333,9 @@ def replace(m): if url not in by_url: try: with urllib.request.urlopen(url, timeout=30) as resp: - data, ctype = resp.read(), resp.headers.get_content_type() + data, ctype = resp.read(MAX_IMAGE_BYTES + 1), resp.headers.get_content_type() + if len(data) > MAX_IMAGE_BYTES: + raise ValueError(f"larger than {MAX_IMAGE_BYTES // 2**20} MiB") except (OSError, ValueError) as e: print(f"warning: signature image {url} not embedded ({e}); kept as a link", file=sys.stderr) @@ -382,13 +399,15 @@ def with_signature(msg, sig): paragraphs = "".join(f"

{html.escape(p).replace(chr(10), '
')}

\n" for p in re.split(r"\n\s*\n", body) if p.strip()) sep_html = "--
\n" if sig["separator"] else "" + # Downloaded only now, so `config` never contacts the images' hosts. + sig_html, images = embed_images(sig["html"]) out.add_alternative( "\n\n\n" - f"{paragraphs}
{sep_html}{sig['html']}
\n" + f"{paragraphs}
{sep_html}{sig_html}
\n" "\n", subtype="html", cte="8bit") html_part = out.get_payload()[1] - for cid, data, maintype, subtype in sig["images"]: + for cid, data, maintype, subtype in images: html_part.add_related(data, maintype=maintype, subtype=subtype, cid=f"<{cid}>", disposition="inline", filename=f"signature.{subtype}") return out @@ -403,9 +422,12 @@ def draft(path): if not msg["From"]: msg["From"] = sender # Without a Date, mail clients list the draft out of place (Thunderbird sorts it as undated). + # An empty header is replaced: these headers can appear only once. if not msg["Date"]: + del msg["Date"] msg["Date"] = email.utils.formatdate(localtime=True) if not msg["Message-ID"]: + del msg["Message-ID"] msg["Message-ID"] = email.utils.make_msgid(domain=sender.partition("@")[2] or None) sig = signature() if sig: