feat(mail): save sent emails to the IMAP Sent folder

SMTP does not file a copy of outgoing mail, so append each sent message
to the Sent folder over IMAP (special-use \Sent, common names, else
create "Sent"). Best-effort: a failed copy is logged and never fails
the send.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
localhost 2026-09-23 21:19:30 +02:00
parent 82e3669bf7
commit 79a7afdd80
1 changed files with 51 additions and 6 deletions

View File

@ -1,4 +1,7 @@
import nodemailer from 'nodemailer'
import MailComposer from 'nodemailer/lib/mail-composer'
import type Mail from 'nodemailer/lib/mailer'
import { ImapFlow } from 'imapflow'
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
@ -9,23 +12,65 @@ const transporter = nodemailer.createTransport({
}
})
const domain = () => (process.env.SMTP_FROM || '').match(/@([^>\s]+)/)?.[1] || 'preservetube.com'
// smtp does not file a copy anywhere, so put it in the imap Sent folder ourselves
async function saveToSent(options: Mail.Options) {
if (!process.env.IMAP_HOST || !process.env.IMAP_USER || !process.env.IMAP_PASS) return
const port = Number(process.env.IMAP_PORT || 993)
const client = new ImapFlow({
host: process.env.IMAP_HOST,
port,
secure: port === 993,
auth: { user: process.env.IMAP_USER, pass: process.env.IMAP_PASS },
logger: false
})
client.on('error', (error: Error) => console.log(`[mail] imap connection error: ${error.message}`))
try {
const raw = await new MailComposer(options).compile().build()
await client.connect()
const boxes = await client.list()
const sent = boxes.find(box => box.specialUse === '\\Sent')?.path
|| boxes.find(box => /^(inbox[./])?sent( items| messages)?$/i.test(box.path))?.path
let path = sent
if (!path) {
path = 'Sent'
await client.mailboxCreate(path).catch(() => {})
}
await client.append(path, raw, ['\\Seen'], options.date instanceof Date ? options.date : new Date())
} catch (error: unknown) {
// the mail itself already went out, a missing copy must never fail the caller
console.log(`[mail] could not save copy to Sent: ${(error as Error).message}`)
} finally {
await client.logout().catch(() => {})
}
}
async function sendMail(options: Mail.Options) {
const full: Mail.Options = { ...options, from: process.env.SMTP_FROM, date: new Date() }
await transporter.sendMail(full)
await saveToSent(full)
}
async function sendRestoreCompleteEmail(to: string, videoId: string, title?: string | null) {
const watchUrl = `https://preservetube.com/watch?v=${videoId}`
await transporter.sendMail({
from: process.env.SMTP_FROM,
await sendMail({
to,
subject: 'Your PreserveTube video has been restored',
messageId: `<${crypto.randomUUID()}@${domain()}>`,
text: `The video you requested${title ? ` ("${title}")` : ''} has been restored from cold storage and is available again:\n\n${watchUrl}`
})
}
async function sendArchiveReplyEmail(to: string, subject: string, text: string, inReplyTo?: string) {
const domain = (process.env.SMTP_FROM || '').match(/@([^>\s]+)/)?.[1] || 'preservetube.com'
const messageId = `<${crypto.randomUUID()}@${domain}>`
const messageId = `<${crypto.randomUUID()}@${domain()}>`
await transporter.sendMail({
from: process.env.SMTP_FROM,
await sendMail({
to,
subject: /^re:/i.test(subject) ? subject : `Re: ${subject}`,
text,