gmail-summarize
Fetch recent unread Gmail (yesterday + today) and send a digest to the user.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install openclaw-skills-gmail-summarize
Repository
Skill path: skills/2p1c/gmail-summarize
Fetch recent unread Gmail (yesterday + today) and send a digest to the user.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: openclaw.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install gmail-summarize into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/openclaw/skills before adding gmail-summarize to shared team environments
- Use gmail-summarize for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: gmail-summarize
description: Fetch recent unread Gmail (yesterday + today) and send a digest to the user.
metadata: {
"skill": {
"emoji": "๐ฌ",
"requires": {
"runtime": "python3",
"env_vars": {
"required": ["IMAP_USERNAME", "IMAP_PASSWORD"],
"optional": ["IMAP_HOST", "IMAP_PORT", "IMAP_MAX_BODY_CHARS", "EMAIL_CONFIG_PATH"]
},
"credentials": [
"IMAP_USERNAME (env, required) โ IMAP login username",
"IMAP_PASSWORD (env, required) โ IMAP login password / app-password",
"IMAP_HOST (env, optional, default: imap.gmail.com)",
"IMAP_PORT (env, optional, default: 993)"
],
"config": "$EMAIL_CONFIG_PATH or ~/.config/gmail-summarize/config.json (used only when IMAP_USERNAME/IMAP_PASSWORD env vars are absent)",
"config_fields": ["email.imapHost", "email.imapPort", "email.imapUsername", "email.imapPassword"],
"external_connections": ["IMAP server specified in IMAP_HOST or email.imapHost"]
}
}
}
---
# Gmail Recent Digest
## Requirements
- **Python 3** must be available in the container environment (`python` command).
- IMAP credentials must be supplied via **one** of the following methods (env vars take precedence):
### Option A โ Environment variables (recommended)
| Variable | Description | Default |
|---|---|---|
| `IMAP_HOST` | IMAP server hostname | `imap.gmail.com` |
| `IMAP_PORT` | IMAP server port | `993` |
| `IMAP_USERNAME` | IMAP login username | *(required)* |
| `IMAP_PASSWORD` | IMAP login password / app-password | *(required)* |
| `IMAP_MAX_BODY_CHARS` | Max body characters per email | `2000` |
### Option B โ Config file
Set `EMAIL_CONFIG_PATH` to point to a JSON file, or place the file at `~/.config/gmail-summarize/config.json`.
The file must contain **only** the fields below (no other sensitive data should be stored in this file):
```json
{
"email": {
"imapHost": "imap.gmail.com",
"imapPort": 993,
"imapUsername": "[email protected]",
"imapPassword": "your-app-password",
"maxBodyChars": 2000
}
}
```
## Security notes
- **Preferred**: supply credentials via environment variables (`IMAP_HOST`, `IMAP_PORT`, `IMAP_USERNAME`, `IMAP_PASSWORD`) so no file on disk is read at all.
- When a config file is used, it should contain **only** the `email` fields listed above. The script reads only those four fields and nothing else from the file.
- The only external connection made is to the IMAP server declared in `IMAP_HOST` / `email.imapHost`. No other endpoints are contacted.
- Credentials are used solely to authenticate the IMAP session and are not logged or stored elsewhere by this skill.
## When to use
- User asks "check my Gmail", "summarize my emails", "้ฎไปถๆ่ฆ"
- Cron trigger message contains "gmail_digest"
## Workflow
1. Run the fetch script via exec tool:
`python {workspace}/skills/gmail-summarize/scripts/fetch_unseen.py`
(replace {workspace} with your actual workspace root)
2. Parse the JSON array. Each item has: sender, subject, date, body
3. For each email compose one line:
`[date] sender | subject โ one-sentence body summary`
4. Send the full digest via MessageTool in this format:
๐ฌ **้ฎไปถๆ่ฆ** (Nๅฐ๏ผ่ฆ็ MM/DDโMM/DD)
โข [ๆฅๆ] ๅไปถไบบ | ไธป้ข โ ไธๅฅ่ฏๆ่ฆ
โข [ๆฅๆ] ๅไปถไบบ | ไธป้ข โ ไธๅฅ่ฏๆ่ฆ
...
5. If result is empty, send: ๐ญ ่ฟไธคๆฅๆๆ ๆช่ฏป้ฎไปถ
## Output Rules
- Send the digest message ONLY. Do NOT add any extra comments, greetings, explanations, or follow-up questions before or after the digest.
- Do NOT say things like "ไธปไบบ๏ผไปฅไธๆฏๆจ็้ฎไปถๆ่ฆ" or "ๅฆ้ไบ่งฃ่ฏฆๆ
่ฏทๅ็ฅ" etc.
- The digest message itself is the complete and final response.
- The one-sentence body summary MUST be translated into Chinese. Sender names and subjects should keep their original text as-is.
---
## Skill Companion Files
> Additional files collected from the skill directory layout.
### _meta.json
```json
{
"owner": "2p1c",
"slug": "gmail-summarize",
"displayName": "Gmail-digester",
"latest": {
"version": "1.1.4",
"publishedAt": 1772790533822,
"commit": "https://github.com/openclaw/skills/commit/a4c8445a0d2d482ae40dae7db5d73786464d1ea1"
},
"history": []
}
```
### scripts/fetch_unseen.py
```python
#!/usr/bin/env python3
"""Fetch unread Gmail from yesterday and today, print as JSON."""
import html as _html
import imaplib
import json
import os
import re
from datetime import date, datetime, timedelta
from email import policy
from email.header import decode_header, make_header
from email.parser import BytesParser
from email.utils import parseaddr, parsedate_to_datetime
from pathlib import Path
# โโ Config โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Priority 1: individual environment variables (no file read needed)
_env_host = os.environ.get("IMAP_HOST", "").strip()
_env_port = os.environ.get("IMAP_PORT", "").strip()
_env_username = os.environ.get("IMAP_USERNAME", "").strip()
_env_password = os.environ.get("IMAP_PASSWORD", "").strip()
_env_maxchars = os.environ.get("IMAP_MAX_BODY_CHARS", "").strip()
if _env_username and _env_password:
# Credentials supplied entirely via env โ no config file required
IMAP_HOST = _env_host or "imap.gmail.com"
IMAP_PORT = int(_env_port) if _env_port else 993
USERNAME = _env_username
PASSWORD = _env_password
MAX_CHARS = min(int(_env_maxchars) if _env_maxchars else 2000, 2000)
else:
# Priority 2: config file (EMAIL_CONFIG_PATH or default location)
# The config file should contain ONLY the email fields listed in SKILL.md.
config_path_env = os.environ.get("EMAIL_CONFIG_PATH", "").strip()
if config_path_env:
config_path = Path(config_path_env).expanduser()
else:
config_path = Path.home() / ".config" / "gmail-summarize" / "config.json"
cfg = json.loads(config_path.read_text())
email_cfg = cfg.get("email", {})
IMAP_HOST = _env_host or email_cfg.get("imapHost", "imap.gmail.com")
IMAP_PORT = int(_env_port) if _env_port else int(email_cfg.get("imapPort", 993))
USERNAME = _env_username or email_cfg.get("imapUsername", "")
PASSWORD = _env_password or email_cfg.get("imapPassword", "")
MAX_CHARS = min(int(_env_maxchars) if _env_maxchars else int(email_cfg.get("maxBodyChars", 2000)), 2000)
# โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
IMAP_MONTHS = ["Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"]
def fmt_imap_date(d: date) -> str:
return f"{d.day:02d}-{IMAP_MONTHS[d.month-1]}-{d.year}"
def decode_hdr(v: str) -> str:
try:
return str(make_header(decode_header(v or "")))
except Exception:
return v or ""
def html_to_text(h: str) -> str:
h = re.sub(r"<br\s*/?>", "\n", h, flags=re.I)
h = re.sub(r"</p>", "\n", h, flags=re.I)
h = re.sub(r"<[^>]+>", "", h)
return _html.unescape(h)
def extract_body(msg) -> str:
if msg.is_multipart():
plains, htmls = [], []
for part in msg.walk():
if part.get_content_disposition() == "attachment":
continue
ct = part.get_content_type()
try:
payload = part.get_content()
except Exception:
b = part.get_payload(decode=True) or b""
payload = b.decode(part.get_content_charset() or "utf-8", errors="replace")
if not isinstance(payload, str):
continue
if ct == "text/plain":
plains.append(payload)
elif ct == "text/html":
htmls.append(payload)
if plains:
return "\n\n".join(plains).strip()
if htmls:
return html_to_text("\n\n".join(htmls)).strip()
return ""
try:
payload = msg.get_content()
except Exception:
b = msg.get_payload(decode=True) or b""
payload = b.decode(msg.get_content_charset() or "utf-8", errors="replace")
if not isinstance(payload, str):
return ""
if msg.get_content_type() == "text/html":
return html_to_text(payload).strip()
return payload.strip()
# โโ Date range โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
today = date.today()
yesterday = today - timedelta(days=1)
tomorrow = today + timedelta(days=1)
# Python-side cutoff: yesterday 00:00:00 local time (naive)
cutoff_dt = datetime(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0)
# โโ Fetch via IMAP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
client.login(USERNAME, PASSWORD)
client.select("INBOX")
# Use SINCE one day earlier as a wide net to compensate for UTC offset,
# then filter precisely on the Python side.
# X-GM-RAW restricts results to Gmail's Primary category only.
wide_since = yesterday - timedelta(days=1)
_, data = client.search(None,
"SINCE", fmt_imap_date(wide_since),
"BEFORE", fmt_imap_date(tomorrow),
"UNSEEN",
"X-GM-RAW", "category:primary",
)
ids = data[0].split() if data and data[0] else []
results = []
for imap_id in ids:
_, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
if not fetched:
continue
raw = next((bytes(x[1]) for x in fetched if isinstance(x, tuple) and len(x) >= 2), None)
if not raw:
continue
parsed = BytesParser(policy=policy.default).parsebytes(raw)
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
subject = decode_hdr(parsed.get("Subject", ""))
date_str = parsed.get("Date", "")
body = extract_body(parsed)[:MAX_CHARS] or "(empty)"
# โโ Python-side date filter (precise, timezone-aware) โโโโโโโโโโโโโโโโโโโโ
try:
msg_dt = parsedate_to_datetime(date_str) # aware datetime
msg_local = msg_dt.astimezone().replace(tzinfo=None) # convert to local naive
if msg_local < cutoff_dt:
continue # older than yesterday 00:00 local โ skip
except Exception:
pass # if date unparseable, include it rather than silently drop
results.append({
"sender": sender,
"subject": subject,
"date": date_str,
"body": body,
})
client.logout()
output = json.dumps(results, ensure_ascii=False, indent=2)
# Guard: exec tool has a 10,000-char output limit; truncate gracefully if needed.
if len(output) > 9000:
output = json.dumps(results, ensure_ascii=False, separators=(",", ":"))
print(output)
```