Skip to content

Commit 1ccc3e5

Browse files
committed
feat: logging
1 parent 9367ad0 commit 1ccc3e5

3 files changed

Lines changed: 105 additions & 99 deletions

File tree

package-lock.json

Lines changed: 25 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"nodemailer": "^7.0.5"
1818
},
1919
"devDependencies": {
20-
"@types/node": "^24.2.0"
20+
"@types/mailparser": "^3.4.6",
21+
"@types/node": "^24.2.0",
22+
"@types/nodemailer": "^7.0.11"
2123
}
2224
}

src/forward.ts

Lines changed: 77 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,27 @@ const PROCESSED_FOLDER = process.env.PROCESSED_FOLDER || 'Forwarded';
2121
const DAEMON = (process.env.DAEMON || '').toLowerCase() === 'true';
2222
const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 60000);
2323

24+
const LOG_LEVELS = { error: 0, warn: 1, info: 2, debug: 3 } as const;
25+
type LogLevel = keyof typeof LOG_LEVELS;
26+
27+
const configuredLevel = (process.env.LOG_LEVEL?.toLowerCase() ?? 'info') as LogLevel;
28+
const currentLogLevel = LOG_LEVELS[configuredLevel] ?? LOG_LEVELS.info;
29+
30+
const logger = {
31+
error: (msg: string, ...args: any[]) => {
32+
if (currentLogLevel >= LOG_LEVELS.error) console.error(`[ERROR] ${msg}`, ...args);
33+
},
34+
warn: (msg: string, ...args: any[]) => {
35+
if (currentLogLevel >= LOG_LEVELS.warn) console.warn(`[WARN] ${msg}`, ...args);
36+
},
37+
info: (msg: string, ...args: any[]) => {
38+
if (currentLogLevel >= LOG_LEVELS.info) console.log(`[INFO] ${msg}`, ...args);
39+
},
40+
debug: (msg: string, ...args: any[]) => {
41+
if (currentLogLevel >= LOG_LEVELS.debug) console.log(`[DEBUG] ${msg}`, ...args);
42+
},
43+
};
44+
2445
function validateEnvironmentVariables() {
2546
const requiredVars = [
2647
"IMAP_HOST",
@@ -38,45 +59,20 @@ function validateEnvironmentVariables() {
3859
const missingVars = requiredVars.filter((varName) => !process.env[varName]);
3960

4061
if (missingVars.length > 0) {
41-
console.error(
42-
"Missing required environment variables:",
43-
missingVars.join(", ")
44-
);
62+
logger.error("Missing required environment variables: " + missingVars.join(", "));
4563
process.exit(1);
4664
}
4765

48-
console.log("All required environment variables are set.");
49-
50-
// Log domain filtering configuration
66+
logger.debug("All required environment variables are set.");
67+
5168
if (ALLOWED_SENDER_DOMAINS) {
52-
console.log("Domain filtering enabled. Allowed domains:", ALLOWED_SENDER_DOMAINS);
69+
logger.info("Domain filtering enabled. Allowed domains: " + ALLOWED_SENDER_DOMAINS);
5370
} else {
54-
console.log("Domain filtering disabled. All emails will be forwarded.");
71+
logger.debug("Domain filtering disabled. All emails will be forwarded.");
5572
}
5673
}
5774

58-
// Custom logger for ImapFlow using console statements
59-
const customLogger = {
60-
trace: () => {}, // Suppress trace logs
61-
debug: () => {}, // Suppress debug logs
62-
info: (msg: any, ...args: any[]) => {
63-
// Only log important info messages
64-
if (typeof msg === 'string' && (msg.includes('Connected') || msg.includes('Authenticated'))) {
65-
console.log('IMAP:', msg, ...args);
66-
}
67-
},
68-
warn: (msg: any, ...args: any[]) => {
69-
console.warn('IMAP Warning:', msg, ...args);
70-
},
71-
error: (msg: any, ...args: any[]) => {
72-
console.error('IMAP Error:', msg, ...args);
73-
},
74-
fatal: (msg: any, ...args: any[]) => {
75-
console.error('IMAP Fatal:', msg, ...args);
76-
}
77-
};
78-
79-
function shouldForwardEmail(email) {
75+
function shouldForwardEmail(email: import('mailparser').ParsedMail) {
8076
// If no domain filtering is configured, forward all emails
8177
if (!ALLOWED_SENDER_DOMAINS) {
8278
return true;
@@ -86,15 +82,15 @@ function shouldForwardEmail(email) {
8682
const fromAddress = email.from?.value?.[0]?.address || email.from?.text || '';
8783

8884
if (!fromAddress) {
89-
console.log("No sender address found, skipping email");
85+
logger.debug("No sender address found, skipping email");
9086
return false;
9187
}
9288

9389
// Extract domain from sender email
9490
const senderDomain = fromAddress.split('@')[1]?.toLowerCase();
95-
91+
9692
if (!senderDomain) {
97-
console.log("Invalid sender email format:", fromAddress);
93+
logger.warn(`Invalid sender email format: ${fromAddress}`);
9894
return false;
9995
}
10096

@@ -105,15 +101,22 @@ function shouldForwardEmail(email) {
105101
.filter(domain => domain.length > 0);
106102

107103
const isAllowed = allowedDomains.includes(senderDomain);
108-
104+
109105
if (!isAllowed) {
110-
console.log(`Email from ${fromAddress} (domain: ${senderDomain}) not in allowed domains: ${allowedDomains.join(', ')}`);
106+
logger.info(`Skipping email from ${fromAddress} domain not in allowlist: ${allowedDomains.join(', ')}`);
111107
}
112108

113109
return isAllowed;
114110
}
115111

116-
async function forwardMail(email) {
112+
interface MailToForward {
113+
subject: string;
114+
text: string;
115+
html: string | undefined;
116+
attachments: { filename: string | undefined; content: Buffer }[];
117+
}
118+
119+
async function forwardMail(email: MailToForward) {
117120
const transporter = nodemailer.createTransport({
118121
host: SMTP_HOST!,
119122
port: parseInt(SMTP_PORT!, 10),
@@ -135,160 +138,137 @@ async function forwardMail(email) {
135138

136139
try {
137140
await transporter.sendMail(mailOptions);
138-
console.log("Forwarded email:", email.subject);
141+
logger.info(`Forwarded email: ${email.subject}`);
139142
} catch (error) {
140-
console.error("Failed to forward email:", email.subject, "Error:", error.message);
143+
logger.error(`Failed to forward email: ${email.subject}${error instanceof Error ? error.message : error}`);
141144
throw error; // Re-throw to let the caller decide how to handle
142145
}
143146
}
144147

145148
async function processUnseen(client: ImapFlow) {
146-
console.log("Starting to process unseen messages...");
147-
149+
logger.debug("Checking for unseen messages...");
150+
148151
// Ensure INBOX is open
149152
if (!client.mailbox || client.mailbox.path !== 'INBOX') {
150-
console.log("Opening INBOX...");
153+
logger.debug("Opening INBOX...");
151154
await client.mailboxOpen('INBOX');
152155
}
153156

154-
console.log("Searching for unseen messages...");
155157
// Fetch all unseen at once to avoid deadlocks
156158
const messages = await client.fetchAll({ seen: false }, { uid: true, envelope: true, source: true });
157-
158-
console.log(`Found ${messages.length} unseen messages`);
159159

160160
if (messages.length === 0) {
161-
console.log("No unseen messages to process");
161+
logger.debug("No unseen messages to process");
162162
return;
163163
}
164164

165+
logger.info(`Found ${messages.length} unseen message(s)`);
166+
165167
for (const msg of messages) {
166-
console.log(`Processing message UID: ${msg.uid}`);
168+
logger.debug(`Processing message UID: ${msg.uid}`);
167169
try {
168170
const parsed = await simpleParser(msg.source as Buffer);
169-
console.log(`Parsed email with subject: ${parsed.subject || '(no subject)'}`);
171+
logger.debug(`Parsed subject: ${parsed.subject || '(no subject)'}`);
170172

171173
if (!shouldForwardEmail(parsed)) {
172-
console.log("Skipping email due to domain filter. Marking as seen...");
174+
logger.debug(`Domain filter: marking UID ${msg.uid} as seen and skipping`);
173175
await client.messageFlagsAdd({ uid: msg.uid }, ['\\Seen']);
174-
console.log("Email marked as seen successfully");
175176
continue;
176177
}
177178

178-
console.log("Email passed domain filter, forwarding...");
179179
await forwardMail({
180180
subject: parsed.subject || '(no subject)',
181181
text: parsed.text || '',
182-
html: parsed.html || undefined,
183-
attachments: (parsed.attachments || []).map(a => ({ filename: a.filename, content: a.content })),
182+
html: parsed.html || undefined, // normalize false/null → undefined
183+
attachments: (parsed.attachments || []).map((a: import('mailparser').Attachment) => ({ filename: a.filename, content: a.content })),
184184
});
185185

186186
// Mark as seen immediately so a failed move doesn't cause re-processing
187187
await client.messageFlagsAdd({ uid: msg.uid }, ['\\Seen'], { uid: true });
188188

189-
console.log(`Moving email to ${PROCESSED_FOLDER}...`);
189+
logger.debug(`Moving UID ${msg.uid} to ${PROCESSED_FOLDER}...`);
190190
await client.messageMove({ uid: msg.uid }, PROCESSED_FOLDER, { uid: true });
191-
console.log("Email moved successfully");
192191
} catch (err) {
193-
console.error('Processing failed for UID', msg.uid, err);
192+
logger.error(`Processing failed for UID ${msg.uid}: ${err}`);
194193
// Leave message untouched for retry
195194
}
196195
}
197-
198-
console.log("Finished processing all unseen messages");
199196
}
200197

201198
async function main() {
202-
console.log("Starting mail forwarder application...");
203-
204-
// Validate all required environment variables are set
199+
logger.info("Starting mail forwarder...");
200+
205201
validateEnvironmentVariables();
206202

207-
console.log("Creating IMAP client...");
208203
const client = new ImapFlow({
209204
host: IMAP_HOST!,
210205
port: parseInt(IMAP_PORT!, 10),
211206
secure: true,
212207
auth: { user: IMAP_USER!, pass: IMAP_PASSWORD! },
213-
logger: false // Disable ImapFlow logging completely
208+
logger: false,
214209
});
215210

216211
try {
217-
console.log("Connecting to IMAP server...");
212+
logger.debug("Connecting to IMAP server...");
218213
await client.connect();
219-
console.log("Connected successfully");
220-
221-
console.log("Opening INBOX...");
214+
logger.info("Connected to IMAP server");
215+
216+
logger.debug("Opening INBOX...");
222217
await client.mailboxOpen('INBOX');
223-
console.log("INBOX opened successfully");
224218

225219
// Ensure processed folder exists
226220
const existing = await client.list();
227221
if (!existing.some(m => m.path === PROCESSED_FOLDER)) {
228-
console.log(`Creating folder: ${PROCESSED_FOLDER}`);
222+
logger.info(`Creating folder: ${PROCESSED_FOLDER}`);
229223
await client.mailboxCreate(PROCESSED_FOLDER);
230224
}
231225

232-
// Initial batch
233-
console.log("Starting initial message processing...");
234226
await processUnseen(client);
235-
console.log("Initial processing complete");
236227

237228
if (DAEMON) {
238-
console.log(`Daemon mode enabled. Polling every ${POLL_INTERVAL_MS} ms`);
229+
logger.info(`Daemon mode: polling every ${POLL_INTERVAL_MS} ms`);
239230
let busy = false;
240231

241232
const poll = async () => {
242233
if (busy) {
243-
console.log("Previous poll still running, skipping...");
234+
logger.debug("Previous poll still running, skipping");
244235
return;
245236
}
246237
busy = true;
247-
console.log("Starting scheduled poll...");
248-
try {
249-
await processUnseen(client);
250-
console.log("Scheduled poll complete");
251-
}
252-
catch (e) {
253-
console.error('Polling error', e);
254-
}
255-
finally {
256-
busy = false;
238+
try {
239+
await processUnseen(client);
240+
} catch (e) {
241+
logger.error(`Polling error: ${e}`);
242+
} finally {
243+
busy = false;
257244
}
258245
};
259246

260247
const timer = setInterval(poll, POLL_INTERVAL_MS);
261248

262249
const shutdown = async (code = 0) => {
263-
console.log("Shutting down daemon...");
250+
logger.info("Shutting down...");
264251
clearInterval(timer);
265252
try { await client.logout(); } catch {}
266253
process.exit(code);
267254
};
268255
process.on('SIGTERM', () => shutdown(0));
269256
process.on('SIGINT', () => shutdown(0));
270257

271-
// Keep process alive
272-
console.log("Daemon running. Press Ctrl+C to stop.");
273-
await new Promise(() => {}); // This keeps the process alive indefinitely
258+
logger.info("Daemon running. Press Ctrl+C to stop.");
259+
await new Promise(() => {}); // Keep process alive
274260
} else {
275-
console.log("Non-daemon mode, logging out...");
276261
await client.logout();
277-
console.log("Logged out, exiting...");
278262
process.exit(0);
279263
}
280264
} catch (error) {
281-
console.error("Error in main function:", error);
282-
try {
283-
await client.logout();
284-
} catch (logoutError) {
285-
console.error("Error during logout:", logoutError);
286-
}
265+
logger.error(`Fatal error: ${error}`);
266+
try { await client.logout(); } catch {}
287267
process.exit(1);
288268
}
289269
}
290270

291271
main().catch(err => {
292-
console.error('Unexpected error in main:', err);
272+
logger.error(`Unexpected error: ${err}`);
293273
process.exit(1);
294274
});

0 commit comments

Comments
 (0)