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