Outlook messages
Outlook .msg emails read: sender, recipients, subject, date, the text, HTML and RTF bodies, the attachments saved, and the message turned into a standard .eml.
Version 1.0.0 · 3 functions · 3 crates
Functions
What use outlook_msg.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use outlook_msg_read, outlook_msg_save_attachments from outlook_msg (Quillo 0.6).
outlook_msg_read(path: str) → anyor an errorThe .msg file at path read: { subject, from, to, cc, bcc, date, message_id, in_reply_to, text, html, rtf, headers, attachments, class }. from and each recipient are { name, email } (the SMTP address, not Exchange's internal one, when the file has it); date is when it was sent, RFC 3339; html and rtf are null when the message has none (rtf is the RTF body, unpacked: rtf_to_text from the rtf plugin reads it); headers the internet headers it came with; attachments [{ name, type, size, content_id, embedded }].
outlook_msg_save_attachments(path: str, folder: str) → listor an errorSaves the message's attachments into folder (made when missing), each under its own name with anything but letters, digits, dots, dashes and spaces made _ (a second file of the same name gets -2): the paths written. Messages attached inside are skipped.
outlook_msg_to_eml(path: str, out: str) → intor an errorThe message written to out as a standard .eml (MIME): headers, the text and HTML bodies, and the attachments, for any mail program, a helpdesk import, or the email_parse plugin. Gives the file's size in bytes.
Get it
$ quillo install outlook_msg # outlook_msg.qwer into this folder$ quillo install outlook_msg --example # and its example, outlook_msg.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/outlook_msg.qwer$ curl -O https://quillolang.org/plugins/files/outlook_msg.qwe$ curl -O https://quillolang.org/plugins/files/outlook_msg_sample.msgThen, in your program, all of it or only what you call:
use outlook_msg.qweruse outlook_msg_read, outlook_msg_save_attachments from outlook_msg // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
cfb = "0.7"encoding_rs = "0.8"base64 = "0.22"Example
// An accounts inbox where people drag Outlook emails into the browser: the .msg read, its attachments saved, and the message// kept as a standard .eml.//// quillo run outlook_msg.qwe then open http://localhost:8080/message, /attachments and /emluse "outlook_msg.qwer"route GET /message { let m = outlook_msg_read("outlook_msg_sample.msg")? { subject: m.subject, from: m.from, to: m.to | map((r) => r.email), cc: m.cc | map((r) => r.email), date: m.date, text: m.text, has_html: m.html != null, rtf: m.rtf, files: m.attachments | map((a) => `${a.name} (${a.type}, ${a.size} bytes)`), }}route GET /attachments { let saved = outlook_msg_save_attachments("outlook_msg_sample.msg", "data/msg-files")? { saved: saved, statement: read_file(saved[0]) }}route GET /eml { let size = outlook_msg_to_eml("outlook_msg_sample.msg", "data/invoice-1042.eml")? let head = split(read_file("data/invoice-1042.eml"), "\r\n") | filter((l) => starts_with(l, "From:") || starts_with(l, "Subject:") || starts_with(l, "Date:")) { bytes: size, head: head }}$ quillo run outlook_msg.qwe$ curl 'localhost:8080/message'$ curl 'localhost:8080/attachments'$ curl 'localhost:8080/eml'Source
//! Outlook .msg emails read: sender, recipients, subject, date, the text and HTML bodies (and the RTF one), the attachments saved, and the whole message turned into a standard .eml.//! version: 1.0.0//!//! [dependencies]//! cfb = "0.7"//! encoding_rs = "0.8"//! base64 = "0.22"use base64::engine::general_purpose::STANDARD;use base64::Engine;use serde_json::{json, Value};use std::collections::HashMap;use std::io::Read;const MAX_STREAM: u64 = 64 * 1024 * 1024;const MAX_ATTACHMENTS: usize = 500;type Cfb = cfb::CompoundFile<std::fs::File>;// One storage's properties: the fixed ones from __properties_version1.0, the others from their __substg1.0_ streams.struct Props { fixed: HashMap<u16, (u16, [u8; 8])>, var: HashMap<u16, (u16, Vec<u8>)>, cp: &'static encoding_rs::Encoding,}impl Props { fn read(c: &mut Cfb, dir: &str, header: usize, cp: &'static encoding_rs::Encoding) -> Result<Props, String> { let mut fixed = HashMap::new(); let mut var = HashMap::new(); let names: Vec<(String, String)> = c .read_storage(if dir.is_empty() { "/" } else { dir }) .map_err(|e| e.to_string())? .filter(|e| e.is_stream()) .map(|e| (e.name().to_string(), e.path().to_string_lossy().into_owned())) .collect(); for (name, path) in names { if name == "__properties_version1.0" { let d = read(c, &path)?; for e in d.get(header..).unwrap_or(&[]).chunks(16).filter(|e| e.len() == 16) { let ty = u16::from_le_bytes([e[0], e[1]]); let id = u16::from_le_bytes([e[2], e[3]]); let mut v = [0u8; 8]; v.copy_from_slice(&e[8..16]); fixed.insert(id, (ty, v)); } } else if let Some(tag) = name.strip_prefix("__substg1.0_").filter(|t| t.len() == 8) { let (Ok(id), Ok(ty)) = (u16::from_str_radix(&tag[..4], 16), u16::from_str_radix(&tag[4..], 16)) else { continue }; // Multi-valued properties (0x1000 bit) come in streams of their own per value: not read here. if ty & 0x1000 == 0 { var.insert(id, (ty, read(c, &path)?)); } } } let mut p = Props { fixed, var, cp }; // The message's code page, for its 8-bit strings. for id in [0x3FFD, 0x3FDE] { if let Some(n) = p.int(id) { p.cp = codepage(n); break; } } Ok(p) } fn str(&self, id: u16) -> Option<String> { let (ty, d) = self.var.get(&id)?; let s = match ty { 0x001F => { let u: Vec<u16> = d.chunks(2).filter(|c| c.len() == 2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); String::from_utf16_lossy(&u) } 0x001E => self.cp.decode(d).0.into_owned(), 0x0102 => self.cp.decode(d).0.into_owned(), _ => return None, }; let s = s.trim_end_matches('\0').to_string(); if s.is_empty() { None } else { Some(s) } } fn bin(&self, id: u16) -> Option<&[u8]> { self.var.get(&id).filter(|(t, _)| *t == 0x0102).map(|(_, d)| d.as_slice()) } fn int(&self, id: u16) -> Option<i64> { let (ty, v) = self.fixed.get(&id)?; match ty { 0x0003 => Some(i32::from_le_bytes([v[0], v[1], v[2], v[3]]) as i64), 0x0002 => Some(i16::from_le_bytes([v[0], v[1]]) as i64), 0x0014 => Some(i64::from_le_bytes(*v)), 0x000B => Some((v[0] != 0) as i64), _ => None, } } fn time(&self, id: u16) -> Option<String> { let (ty, v) = self.fixed.get(&id)?; if *ty != 0x0040 { return None; } let ft = u64::from_le_bytes(*v); if ft == 0 { return None; } Some(rfc3339(ft as i64 / 10_000_000 - 11_644_473_600)) }}fn codepage(n: i64) -> &'static encoding_rs::Encoding { let label = match n { 65001 => "utf-8".to_string(), 20127 => "us-ascii".to_string(), 28591 => "iso-8859-1".to_string(), 932 => "shift_jis".to_string(), 936 => "gbk".to_string(), 949 => "euc-kr".to_string(), 950 => "big5".to_string(), n => format!("windows-{}", n), }; encoding_rs::Encoding::for_label(label.as_bytes()).unwrap_or(encoding_rs::WINDOWS_1252)}fn read(c: &mut Cfb, path: &str) -> Result<Vec<u8>, String> { let s = c.open_stream(path).map_err(|e| format!("{}: {}", path, e))?; let mut d = Vec::new(); s.take(MAX_STREAM + 1).read_to_end(&mut d).map_err(|e| e.to_string())?; if d.len() as u64 > MAX_STREAM { return Err(format!("{}: more than 64 MB", path)); } Ok(d)}fn rfc3339(secs: i64) -> String { let days = secs.div_euclid(86_400); let s = secs.rem_euclid(86_400); let z = days + 719_468; let era = z.div_euclid(146_097); let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 }; format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, s / 3600, s / 60 % 60, s % 60)}// Compressed RTF (MS-OXRTFCP): LZ77 over a 4 KB dictionary that starts with this text.const RTF_PREBUF: &[u8] = b"{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript \\fdecor MS Sans SerifSymbolArialTimes New RomanCourier{\\colortbl\\red0\\green0\\blue0\r\n\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx";fn rtf_decompress(d: &[u8]) -> Option<String> { if d.len() < 16 { return None; } let raw = u32::from_le_bytes([d[4], d[5], d[6], d[7]]) as usize; let body = &d[16..]; if &d[8..12] == b"MELA" { return Some(String::from_utf8_lossy(&body[..raw.min(body.len())]).into_owned()); } if &d[8..12] != b"LZFu" { return None; } let mut dict = [0u8; 4096]; dict[..RTF_PREBUF.len()].copy_from_slice(RTF_PREBUF); let mut w = RTF_PREBUF.len(); let mut out = Vec::with_capacity(raw.min(MAX_STREAM as usize)); let mut i = 0; 'outer: while i < body.len() { let ctl = body[i]; i += 1; for bit in 0..8 { if i >= body.len() { break 'outer; } if ctl & (1 << bit) == 0 { let b = body[i]; i += 1; out.push(b); dict[w] = b; w = (w + 1) % 4096; } else { if i + 1 >= body.len() { break 'outer; } let r = u16::from_be_bytes([body[i], body[i + 1]]) as usize; i += 2; let (off, len) = (r >> 4, (r & 0xF) + 2); if off == w { break 'outer; } for k in 0..len { let b = dict[(off + k) % 4096]; out.push(b); dict[w] = b; w = (w + 1) % 4096; } } if out.len() >= raw { break 'outer; } } } out.truncate(raw); Some(String::from_utf8_lossy(&out).into_owned())}struct Attachment { name: String, mime: Option<String>, content_id: Option<String>, data: Vec<u8>, embedded: bool,}struct Msg { p: Props, recipients: Vec<(i64, Option<String>, Option<String>)>, attachments: Vec<Attachment>,}fn open(path: &str) -> Result<Msg, String> { let mut c = cfb::open(path).map_err(|e| format!("{}: not an Outlook .msg file ({})", path, e))?; if !c.exists("/__properties_version1.0") { return Err(format!("{}: a compound file, but not an Outlook message", path)); } let p = Props::read(&mut c, "/", 32, encoding_rs::WINDOWS_1252)?; let dirs: Vec<(String, String)> = c.read_root_storage().filter(|e| e.is_storage()).map(|e| (e.name().to_string(), e.path().to_string_lossy().into_owned())).collect(); let mut recipients = vec![]; let mut attachments = vec![]; let mut dirs = dirs; dirs.sort(); for (name, path) in dirs { if name.starts_with("__recip_version1.0_") { let r = Props::read(&mut c, &path, 8, p.cp)?; let email = r.str(0x39FE).or_else(|| r.str(0x3003).filter(|e| e.contains('@'))); recipients.push((r.int(0x0C15).unwrap_or(1), r.str(0x3001), email)); } else if name.starts_with("__attach_version1.0_") && attachments.len() < MAX_ATTACHMENTS { let a = Props::read(&mut c, &path, 8, p.cp)?; let method = a.int(0x3705).unwrap_or(1); let embedded = method == 5; // A message attached to the message: its own storage; given here as the inner message's subject, as an .msg name. let name = a.str(0x3707).or_else(|| a.str(0x3704)).or_else(|| a.str(0x3001)).unwrap_or_else(|| format!("attachment-{}", attachments.len() + 1)); let data = if embedded { vec![] } else { a.bin(0x3701).map(|d| d.to_vec()).unwrap_or_default() }; if method == 6 && data.is_empty() { continue; } attachments.push(Attachment { name, mime: a.str(0x370E), content_id: a.str(0x3712), data, embedded }); } } Ok(Msg { p, recipients, attachments })}fn person(name: Option<String>, email: Option<String>) -> Value { json!({ "name": name, "email": email })}fn html(m: &Msg) -> Option<String> { m.p.str(0x1013)}/// The .msg file at `path` read: `{ subject, from, to, cc, bcc, date, message_id, in_reply_to, text, html, rtf, headers, attachments, class }`./// `from` and each recipient are `{ name, email }` (the SMTP address, not Exchange's internal one, when the file has it); `date` is when it/// was sent, RFC 3339; `html` and `rtf` are null when the message has none (`rtf` is the RTF body, unpacked: `rtf_to_text` from the rtf/// plugin reads it); `headers` the internet headers it came with; `attachments` `[{ name, type, size, content_id, embedded }]`.pub fn outlook_msg_read(path: &str) -> Result<Value, String> { let m = open(path)?; let p = &m.p; let sender_email = p.str(0x5D01).or_else(|| p.str(0x0C1F).filter(|e| e.contains('@'))).or_else(|| p.str(0x0065).filter(|e| e.contains('@'))); let sender_name = p.str(0x0C1A).or_else(|| p.str(0x0042)); let who = |t: i64| -> Vec<Value> { m.recipients.iter().filter(|r| r.0 == t).map(|r| person(r.1.clone(), r.2.clone())).collect() }; Ok(json!({ "subject": p.str(0x0037), "from": person(sender_name, sender_email), "to": who(1), "cc": who(2), "bcc": who(3), "date": p.time(0x0039).or_else(|| p.time(0x0E06)), "message_id": p.str(0x1035), "in_reply_to": p.str(0x1042), "text": p.str(0x1000), "html": html(&m), "rtf": p.bin(0x1009).and_then(rtf_decompress), "headers": p.str(0x007D), "class": p.str(0x001A), "attachments": m.attachments.iter().map(|a| json!({ "name": a.name, "type": a.mime, "size": a.data.len(), "content_id": a.content_id, "embedded": a.embedded })).collect::<Vec<_>>(), }))}fn safe_name(name: &str, i: usize) -> String { let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '.' | '-' | '_' | ' ') { c } else { '_' }).collect(); let s = s.trim().trim_start_matches('.').to_string(); if s.is_empty() { format!("attachment-{}", i + 1) } else { s }}/// Saves the message's attachments into `folder` (made when missing), each under its own name with anything but letters, digits, dots,/// dashes and spaces made `_` (a second file of the same name gets `-2`): the paths written. Messages attached inside are skipped.pub fn outlook_msg_save_attachments(path: &str, folder: &str) -> Result<Vec<String>, String> { let m = open(path)?; std::fs::create_dir_all(folder).map_err(|e| format!("{}: {}", folder, e))?; let mut out: Vec<String> = vec![]; for (i, a) in m.attachments.iter().enumerate().filter(|(_, a)| !a.embedded) { let base = safe_name(&a.name, i); let mut p = std::path::Path::new(folder).join(&base); let mut n = 2; while out.contains(&p.to_string_lossy().into_owned()) { let (stem, ext) = base.rsplit_once('.').map(|(s, e)| (s.to_string(), format!(".{}", e))).unwrap_or((base.clone(), String::new())); p = std::path::Path::new(folder).join(format!("{}-{}{}", stem, n, ext)); n += 1; } std::fs::write(&p, &a.data).map_err(|e| format!("{}: {}", p.display(), e))?; out.push(p.to_string_lossy().into_owned()); } Ok(out)}fn enc_header(s: &str) -> String { let s = s.replace(['\r', '\n'], " "); if s.is_ascii() { s } else { format!("=?UTF-8?B?{}?=", STANDARD.encode(s.as_bytes())) }}fn addr(v: &Value) -> Option<String> { let email = v["email"].as_str()?; Some(match v["name"].as_str().filter(|n| !n.is_empty() && *n != email) { Some(n) if n.is_ascii() => format!("\"{}\" <{}>", n.replace(['"', '\\'], ""), email), Some(n) => format!("{} <{}>", enc_header(n), email), None => email.to_string(), })}fn b64_lines(d: &[u8]) -> String { let s = STANDARD.encode(d); s.as_bytes().chunks(76).map(|c| String::from_utf8_lossy(c).into_owned()).collect::<Vec<_>>().join("\r\n")}/// The message written to `out` as a standard .eml (MIME): headers, the text and HTML bodies, and the attachments, for any mail program,/// a helpdesk import, or the email_parse plugin. Gives the file's size in bytes.pub fn outlook_msg_to_eml(path: &str, out: &str) -> Result<i64, String> { let v = outlook_msg_read(path)?; let m = open(path)?; let mut h = vec![]; if let Some(f) = addr(&v["from"]) { h.push(format!("From: {}", f)); } for (k, name) in [("to", "To"), ("cc", "Cc")] { let list: Vec<String> = v[k].as_array().into_iter().flatten().filter_map(addr).collect(); if !list.is_empty() { h.push(format!("{}: {}", name, list.join(", "))); } } h.push(format!("Subject: {}", enc_header(v["subject"].as_str().unwrap_or("")))); if let Some(d) = v["date"].as_str() { // RFC 3339 to RFC 5322: "2026-10-27T14:05:00Z" is "27 Oct 2026 14:05:00 +0000". let months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; let mo: usize = d[5..7].parse().unwrap_or(1); h.push(format!("Date: {} {} {} {} +0000", &d[8..10], months[mo.clamp(1, 12) - 1], &d[0..4], &d[11..19])); } for (k, name) in [("message_id", "Message-ID"), ("in_reply_to", "In-Reply-To")] { if let Some(x) = v[k].as_str() { h.push(format!("{}: {}", name, x.replace(['\r', '\n'], ""))); } } h.push("MIME-Version: 1.0".into()); let text = v["text"].as_str().unwrap_or("").to_string(); let body_html = v["html"].as_str().map(String::from); let alt = |b: &str| -> String { let mut s = format!("Content-Type: multipart/alternative; boundary=\"{}\"\r\n\r\n", b); s.push_str(&format!("--{}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{}\r\n", b, b64_lines(text.as_bytes()))); if let Some(hb) = &body_html { s.push_str(&format!("--{}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{}\r\n", b, b64_lines(hb.as_bytes()))); } s.push_str(&format!("--{}--\r\n", b)); s }; let files: Vec<&Attachment> = m.attachments.iter().filter(|a| !a.embedded).collect(); let mut eml = h.join("\r\n"); eml.push_str("\r\n"); if files.is_empty() { eml.push_str(&alt("=_quillo_alt")); } else { let b = "=_quillo_mixed"; eml.push_str(&format!("Content-Type: multipart/mixed; boundary=\"{}\"\r\n\r\n--{}\r\n", b, b)); eml.push_str(&alt("=_quillo_alt")); for a in files { let name = enc_header(&a.name).replace('"', ""); eml.push_str(&format!( "--{}\r\nContent-Type: {}; name=\"{}\"\r\nContent-Disposition: attachment; filename=\"{}\"\r\nContent-Transfer-Encoding: base64\r\n", b, a.mime.as_deref().unwrap_or("application/octet-stream"), name, name )); if let Some(cid) = &a.content_id { eml.push_str(&format!("Content-ID: <{}>\r\n", cid.trim_matches(['<', '>']))); } eml.push_str(&format!("\r\n{}\r\n", b64_lines(&a.data))); } eml.push_str(&format!("--{}--\r\n", b)); } if let Some(dir) = std::path::Path::new(out).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {}", dir.display(), e))?; } std::fs::write(out, &eml).map_err(|e| format!("{}: {}", out, e))?; Ok(eml.len() as i64)}Related
mbox.qwer · 1.0.0Mailboxes in the mbox format read message by message: lists, search, one message in full with its attachments, each saved as .eml, and messages appended.7 views · 9 downloadsEmail reader email_parse.qwer · 1.0.0Read raw emails: sender, recipients, subject, date, the text and HTML bodies and the attachments, for support inboxes and replies by email.8 views · 6 downloadsIMAP imap.qwer · 1.0.0Inboxes read and filed over IMAP: folders listed, messages searched by unseen, date or sender, fetched and read with their attachments listed, flagged, moved, appended to Sent, and new mail waited for with IDLE.7 views · 7 downloads7-Zip sevenz.qwer · 1.0.07-Zip archives listed, one file read, opened safely into a folder, and made from a folder.7 views · 8 downloadsExcel files spreadsheet.qwer · 1.0.0Read the sheets of .xlsx, .xls and .ods files, and write .xlsx reports with a bold header and fitted columns.7 views · 7 downloadsOpenDocument text odf_text.qwer · 1.0.0The text of LibreOffice files (.odt, .ods, .odp): paragraphs with headings and lists, sheets as rows of typed cells, slides with their notes, and the title and author.7 views · 9 downloadsThread 0 posts
On Outlook messages: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.