From 79a7afdd80be939823ca0ad36d7fe9dce112fe79 Mon Sep 17 00:00:00 2001 From: localhost Date: Wed, 23 Sep 2026 21:19:30 +0200 Subject: [PATCH] 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 --- src/utils/mail.ts | 57 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/src/utils/mail.ts b/src/utils/mail.ts index 94ac666..71ea002 100644 --- a/src/utils/mail.ts +++ b/src/utils/mail.ts @@ -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,