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:
parent
82e3669bf7
commit
79a7afdd80
|
|
@ -1,4 +1,7 @@
|
||||||
import nodemailer from 'nodemailer'
|
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({
|
const transporter = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
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) {
|
async function sendRestoreCompleteEmail(to: string, videoId: string, title?: string | null) {
|
||||||
const watchUrl = `https://preservetube.com/watch?v=${videoId}`
|
const watchUrl = `https://preservetube.com/watch?v=${videoId}`
|
||||||
|
|
||||||
await transporter.sendMail({
|
await sendMail({
|
||||||
from: process.env.SMTP_FROM,
|
|
||||||
to,
|
to,
|
||||||
subject: 'Your PreserveTube video has been restored',
|
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}`
|
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) {
|
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({
|
await sendMail({
|
||||||
from: process.env.SMTP_FROM,
|
|
||||||
to,
|
to,
|
||||||
subject: /^re:/i.test(subject) ? subject : `Re: ${subject}`,
|
subject: /^re:/i.test(subject) ? subject : `Re: ${subject}`,
|
||||||
text,
|
text,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue