feat: initialize newsletter project with IMAP email processing and Discord integration

- Add Cargo.toml for project dependencies
- Create Dockerfile for building and running the application
- Add docker-compose configuration for service management
- Implement email monitoring and processing in main.rs
- Parse emails and send notifications to Discord
- Include configuration file for IMAP and Discord settings
This commit is contained in:
암냥 2026-01-20 13:05:59 +09:00
commit d9cc3c37b2
No known key found for this signature in database
8 changed files with 2040 additions and 0 deletions

53
.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: ci
on:
push:
branches:
- main
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set version
run: |
if [[ "${GITHUB_REF}" == refs/heads/main ]]; then
echo "RELEASE_VERSION=latest" >> $GITHUB_ENV
else
echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/imnyang/newsletter:latest
ghcr.io/imnyang/newsletter:${{ env.RELEASE_VERSION }}
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
.env

1765
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

17
Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "newsletter"
version = "0.1.0"
edition = "2024"
[dependencies]
native-tls = { version = "0.2", features = ["vendored"] }
imap = "2.4"
mailparse = "0.14"
reqwest = { version = "0.11", features = ["blocking", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
chrono = { version = "0.4", features = ["serde"] }

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM rust:slim as builder
WORKDIR /usr/src/app
COPY . .
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/usr/local/cargo/registry cargo build --release
FROM debian:13-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /usr/app
COPY --from=builder /usr/src/app/target/release/newsletter .
CMD ["./newsletter"]

9
compose.yml Normal file
View file

@ -0,0 +1,9 @@
services:
memos-rss:
image: ghcr.io/imnyang/newsletter:latest
restart: always
env_file:
- .env
volumes:
- ./config.toml:/usr/app/config.toml
- /etc/localtime:/etc/localtime:ro

16
config.toml Normal file
View file

@ -0,0 +1,16 @@
imap_server = "imap.gmail.com"
imap_port = 993
imap_username = "newsletter.imnyang@gmail.com"
imap_password = "clff bevg toji slvy"
discord_webhook_url = "https://discord.com/api/webhooks/1463015024799776870/CkA_QMSOz9MIplB-zkgqqK_Blv6BBjFe-MI6KE0Nw8OANTb2jORguyQycVgmIwmuGp8b"
# Ignore emails from these senders (exact match or partial match)
ignored_senders = [
"no-reply@accounts.google.com"
]
# Ignore emails with these subjects (partial match)
ignored_subjects = [
"Security Alert",
"보안"
]

161
src/main.rs Normal file
View file

@ -0,0 +1,161 @@
use mailparse::MailHeaderMap;
use native_tls::TlsConnector;
use serde::Deserialize;
use std::fs;
use std::thread;
use std::time::Duration;
#[derive(Deserialize, Clone)]
struct Config {
imap_server: String,
imap_port: u16,
imap_username: String,
imap_password: String,
discord_webhook_url: String,
ignored_senders: Option<Vec<String>>,
ignored_subjects: Option<Vec<String>>,
}
fn main() {
let config_content = fs::read_to_string("config.toml").expect("Failed to read config.toml");
let config: Config = toml::from_str(&config_content).expect("Failed to parse config.toml");
loop {
println!("Connecting to IMAP server {}:{}...", config.imap_server, config.imap_port);
if let Err(e) = run_monitor(&config) {
eprintln!("Connection lost or error occurred: {}", e);
eprintln!("Retrying in 10 seconds...");
thread::sleep(Duration::from_secs(10));
}
}
}
fn run_monitor(config: &Config) -> Result<(), Box<dyn std::error::Error>> {
let tls = TlsConnector::builder().build()?;
let client = imap::connect((&config.imap_server as &str, config.imap_port), &config.imap_server, &tls)?;
let mut imap_session = client.login(&config.imap_username, &config.imap_password).map_err(|e| e.0)?;
println!("Logged in as {}", config.imap_username);
loop {
imap_session.select("INBOX")?;
// Fetch all messages (including seen ones if we restart, assuming we delete processed ones)
let messages = imap_session.search("ALL")?;
if !messages.is_empty() {
println!("Found {} messages", messages.len());
// Collect sequence numbers to process
let seqs: Vec<u32> = messages.into_iter().collect();
for seq_num in seqs {
// Fetch the message content
let fetches = imap_session.fetch(seq_num.to_string(), "RFC822")?;
if let Some(msg) = fetches.iter().next() {
let body = msg.body().unwrap_or(&[]);
let parsed = mailparse::parse_mail(body)?;
let subject = parsed.headers.get_first_value("Subject").unwrap_or("No Subject".to_string());
let from = parsed.headers.get_first_value("From").unwrap_or("Unknown Sender".to_string());
// Check ignore list
let should_ignore = if let Some(ref senders) = config.ignored_senders {
senders.iter().any(|s| from.contains(s))
} else {
false
} || if let Some(ref subjects) = config.ignored_subjects {
subjects.iter().any(|s| subject.contains(s))
} else {
false
};
if should_ignore {
println!("Ignored email from: {}, Subject: {}", from, subject);
// Delete ignored emails too, to prevent reprocessing?
// Or maybe just skip? If we skip, they remain in INBOX and will be fetched again because search is "ALL".
// To avoid infinite loop of fetching ignored emails, we MUST delete them or mark them differently (and change search query).
// Since user said "Sent messages can be deleted", I will assume ignored messages can also be deleted (skipped).
// If this is risky, I could change search to "UNSEEN" and just mark as seen.
// But let's stick to the previous flow: "Process = Delete". Ignoring is a form of processing.
imap_session.store(seq_num.to_string(), "+FLAGS (\\Deleted)")?;
continue;
}
// Simple body extraction (prioritize text/plain)
let body_content = extract_body(&parsed).unwrap_or("Cannot parse body".to_string());
// Truncate body if too long for Discord (limit is 2000 chars)
let display_body = if body_content.len() > 1500 {
format!("{}...", &body_content[..1500])
} else {
body_content
};
println!("Processing email: {}", subject);
// Send to Discord
let client = reqwest::blocking::Client::new();
let payload = serde_json::json!({
"embeds": [{
"title": subject,
"author": {
"name": from
},
"description": display_body,
"color": 0x5865F2, // Blurple
"timestamp": chrono::Utc::now().to_rfc3339(),
"footer": {
"text": "📰 Newsletter"
}
}]
});
let res = client.post(&config.discord_webhook_url).json(&payload).send();
match res {
Ok(response) => {
if response.status().is_success() {
println!("Sent to Discord. Deleting email...");
imap_session.store(seq_num.to_string(), "+FLAGS (\\Deleted)")?;
} else {
eprintln!("Failed to send to Discord: Status {}", response.status());
}
},
Err(e) => {
eprintln!("Failed to send request to Discord: {}", e);
// Do not delete if failed to send
}
}
}
}
// Permanently remove deleted messages
imap_session.expunge()?;
}
// Wait before next check
thread::sleep(Duration::from_secs(5));
}
}
fn extract_body(parsed: &mailparse::ParsedMail) -> Option<String> {
if parsed.ctype.mimetype == "text/plain" {
return parsed.get_body().ok();
}
// If multipart, search for text/plain
for part in &parsed.subparts {
if let Some(body) = extract_body(part) {
return Some(body);
}
}
// Fallback to text/html if no plain text found (or first part if nothing else)
if parsed.ctype.mimetype == "text/html" {
// Naive stripping of HTML tags could be done here, or just return as is.
return parsed.get_body().ok();
}
None
}