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:
commit
d9cc3c37b2
8 changed files with 2040 additions and 0 deletions
53
.github/workflows/main.yml
vendored
Normal file
53
.github/workflows/main.yml
vendored
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
.env
|
||||||
|
|
||||||
1765
Cargo.lock
generated
Normal file
1765
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal 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
16
Dockerfile
Normal 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
9
compose.yml
Normal 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
16
config.toml
Normal 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
161
src/main.rs
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue